Compare commits

..

13 Commits

Author SHA1 Message Date
Abhi Kumar
f4b9c1bb2b feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
c50a90ac76 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
db03efb495 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
4c36a55188 feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
3bad49e8ed fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
ab4cae6fa1 fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
8b87bd7c3a feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
a0d9e9c8fd feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
dd5b152b34 feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
e1f55cb8df fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
929f96d06d fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
91eb08a97f feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
f4f1630595 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
100 changed files with 4225 additions and 30 deletions

View File

@@ -4305,6 +4305,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5224,6 +5230,101 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
color?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
size?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5233,7 +5334,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6159,6 +6261,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -277,6 +278,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -43,7 +43,9 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,6 +95,7 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],

View File

@@ -0,0 +1,45 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
TEXT: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -14,6 +14,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
@@ -135,6 +136,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -4,6 +4,7 @@ import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
import getGridColor from '../../uPlotLib/utils/getGridColor';
import { buildYAxisSizeCalculator } from '../utils/axis';
import { keepDecadeSplits } from '../utils/logGridSplits';
import { AxisProps, ConfigBuilder } from './types';
/**
@@ -20,8 +21,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
const { grid, isDarkMode, isLogScale } = this.props;
const defaultStroke = getGridColor(isDarkMode ?? false);
const defaultWidth = isLogScale ? 0.1 : 0.2;
const defaultWidth = 0.2;
const defaultShow = true;
const filter = isLogScale ? { filter: keepDecadeSplits } : {};
// Merge partial or full grid config with defaults
if (grid) {
@@ -29,6 +31,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
stroke: grid.stroke ?? defaultStroke,
width: grid.width ?? defaultWidth,
show: grid.show ?? defaultShow,
...filter,
};
}
@@ -36,9 +39,20 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
stroke: defaultStroke,
width: defaultWidth,
show: defaultShow,
...filter,
};
}
/** The axis line reads as the outermost grid line, so it takes the grid's style. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -165,10 +179,13 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
show = true,
side = 2, // bottom by default
space,
splits,
filter,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const border = this.buildBorderConfig(grid);
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
@@ -193,6 +210,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -202,6 +222,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
if (splits) {
axisConfig.splits = splits;
}
if (filter) {
axisConfig.filter = filter;
}
if (size) {
axisConfig.size = size;
}

View File

@@ -3,6 +3,7 @@ import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
import type { AxisProps } from '../types';
import { keepDecadeSplits } from '../../utils/logGridSplits';
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
jest.mock('components/Graph/yAxisConfig', () => ({
@@ -117,9 +118,47 @@ describe('UPlotAxisBuilder', () => {
width: 1,
// show falls back to default when not provided
show: true,
filter: keepDecadeSplits,
});
});
it('draws the axis line by default, styled like the grid', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
).getConfig();
expect(config.border).toStrictEqual({
show: true,
stroke: config.grid?.stroke,
width: 0.5,
});
});
it('leaves the axis line off when showBorder is false', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
).not.toHaveProperty('border');
});
it('passes explicit splits and a label filter through to uPlot', () => {
const splits = jest.fn();
const filter = jest.fn();
const config = new UPlotAxisBuilder(
createAxisProps({ splits, filter }),
).getConfig();
expect(config.splits).toBe(splits);
expect(config.filter).toBe(filter);
});
it('draws log grid lines at powers of ten only, and leaves a linear grid unfiltered', () => {
const log = new UPlotAxisBuilder(createAxisProps({ isLogScale: true }));
const linear = new UPlotAxisBuilder(createAxisProps({ isLogScale: false }));
expect(log.getConfig().grid?.filter).toBe(keepDecadeSplits);
expect(linear.getConfig().grid).not.toHaveProperty('filter');
});
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(

View File

@@ -78,6 +78,12 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit tick positions, replacing uPlot's per-distribution default. */
splits?: uPlot.Axis.Splits;
/** Which splits get a label, replacing uPlot's per-distribution default. */
filter?: uPlot.Axis.Filter;
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
showBorder?: boolean;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
@@ -86,7 +92,7 @@ export interface AxisProps {
space?: number;
/** Picks the dark or light default for stroke and grid color. */
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
/** Axis is on a log scale — grid lines fall on powers of ten only. */
isLogScale?: boolean;
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
* y axis, the only value axis until scatter; a non-time x axis reads it too. */

View File

@@ -42,7 +42,7 @@ function snapToPixel(start: number, length: number): [number, number] {
return [snapped, length + (start - snapped) * 2];
}
function getSeriesStroke(u: uPlot, seriesIdx: number): string {
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
const { stroke } = u.series[seriesIdx];
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
return typeof color === 'string' ? color : '';

View File

@@ -0,0 +1,52 @@
import type uPlot from 'uplot';
import { buildClickData } from '../utils';
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
}));
const event = {
offsetX: 10,
offsetY: 20,
clientX: 110,
clientY: 220,
} as MouseEvent;
describe('buildClickData', () => {
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
const posToIdx = jest.fn(() => {
throw new Error('faceted data[0] is null');
});
const plot = {
data: [null],
posToVal: (pos: number): number => pos * 2,
posToIdx,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toStrictEqual({
xValue: 20,
yValue: 40,
focusedSeries: null,
clickedDataTimestamp: 20,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
});
expect(posToIdx).not.toHaveBeenCalled();
});
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
const plot = {
data: [[100, 200, 300]],
posToVal: (pos: number): number => pos,
posToIdx: (): number => 1,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toMatchObject({
focusedSeries: { seriesIndex: 1 },
clickedDataTimestamp: 200,
});
});
});

View File

@@ -174,6 +174,25 @@ export function createLayoutObserver(
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const pointer = {
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
// A faceted plot (scatter) has no shared x series, so `data[0]` is null and
// `posToIdx` would throw; the hit is the plot's own to resolve.
if (plot.data[0] == null) {
return {
xValue,
yValue,
focusedSeries: null,
clickedDataTimestamp: xValue,
...pointer,
};
}
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
@@ -193,9 +212,6 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
...pointer,
};
}

View File

@@ -0,0 +1,101 @@
import type uPlot from 'uplot';
import {
keepDecadeSplits,
logScaleSplits,
spacedLogLabels,
} from '../logGridSplits';
const filter = (splits: number[]): (number | null)[] =>
keepDecadeSplits({} as uPlot, splits, 0, 0, 0) as (number | null)[];
describe('keepDecadeSplits', () => {
it('keeps the powers of ten and drops the minor splits between them', () => {
expect(filter([1, 2, 5, 9, 10, 20, 100, 300])).toStrictEqual([
1,
null,
null,
null,
10,
null,
100,
null,
]);
});
it('keeps sub-unit decades despite float noise', () => {
expect(filter([0.001, 0.0010000000000000002, 0.002, 0.1])).toStrictEqual([
0.001,
0.0010000000000000002,
null,
0.1,
]);
});
it('keeps zero and negative decades on a symmetric log', () => {
expect(filter([-100, -50, -1, 0, 1, 5])).toStrictEqual([
-100,
null,
-1,
0,
1,
null,
]);
});
});
describe('logScaleSplits', () => {
const splitsFor = (min: number, max: number): number[] =>
(
logScaleSplits as (
u: uPlot,
axisIdx: number,
min: number,
max: number,
) => number[]
)({} as uPlot, 0, min, max);
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
});
it('includes the ends when they fall on a split', () => {
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
});
it('is empty for a range a log scale cannot hold', () => {
expect(splitsFor(0, 10)).toStrictEqual([]);
expect(splitsFor(5, 5)).toStrictEqual([]);
});
});
describe('spacedLogLabels', () => {
// 100px per decade.
const plot = {
axes: [{ scale: 'x' }],
valToPos: (value: number): number => Math.log10(value) * 100,
} as unknown as uPlot;
const label = (splits: number[], space: number): (number | null)[] =>
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
it('always labels the powers of ten', () => {
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
});
it('adds a 2 or 5 that clears the labels already kept', () => {
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
});
it('labels both ends of a range spanning under a decade', () => {
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
20,
null,
100,
null,
500,
]);
});
});

View File

@@ -0,0 +1,76 @@
import type uPlot from 'uplot';
const DECADE_TOLERANCE = 1e-9;
function isDecade(value: number): boolean {
if (value === 0) {
return true;
}
const exponent = Math.log10(Math.abs(value));
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
}
/**
* A log axis splits at every 1–9 × 10ⁿ; a line on each buries the data under
* a mesh. Keeps the powers of ten (and 0, which a symmetric log can include).
*/
export const keepDecadeSplits: uPlot.Axis.Filter = (_u, splits) =>
splits.map((split) => (split != null && isDecade(split) ? split : null));
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
/**
* Splits at 1, 2 and 5 × 10ⁿ inside the scale. uPlot's own log splits step
* from the scale's minimum, so a range not snapped to a power of ten would
* label 10.79, 21.58, … instead.
*/
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
if (!(min > 0) || !(max > min)) {
return [];
}
const splits: number[] = [];
for (
let exponent = Math.floor(Math.log10(min));
exponent <= Math.ceil(Math.log10(max));
exponent++
) {
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
// Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max].
const split = Number((multiple * 10 ** exponent).toPrecision(12));
if (split >= min && split <= max) {
splits.push(split);
}
});
}
return splits;
};
/**
* Labels for `logScaleSplits`: every power of ten, then any 2 or 5 that sits
* at least `space` px from the labels already kept, so a range spanning under
* one decade still reads at both ends.
*/
export const spacedLogLabels: uPlot.Axis.Filter = (
u,
splits,
axisIdx,
space,
) => {
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
const positions = splits.map((split) =>
split == null ? null : u.valToPos(split, scaleKey),
);
const kept = splits.map((split) => split != null && isDecade(split));
const isClear = (index: number): boolean =>
kept.every(
(isKept, other) =>
!isKept ||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
);
splits.forEach((split, index) => {
if (split != null && !kept[index] && isClear(index)) {
kept[index] = true;
}
});
return splits.map((split, index) => (kept[index] ? split : null));
};

View File

@@ -37,6 +37,7 @@ export default function ChartWrapper({
syncKey,
syncFilterMode,
onDestroy = noop,
plotRef,
children,
layoutChildren,
yAxisUnit,
@@ -114,6 +115,7 @@ export default function ChartWrapper({
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;

View File

@@ -0,0 +1,45 @@
import { render } from 'tests/test-utils';
import type uPlot from 'uplot';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import ChartWrapper from '../ChartWrapper';
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
__esModule: true,
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
plotRef?.(FAKE_PLOT);
return null;
},
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartWrapper', () => {
it('hands the plot instance to the caller', () => {
const plotRef = jest.fn();
render(
<ChartWrapper
config={new UPlotConfigBuilder({ id: 'chart' })}
data={[[1], [2]]}
width={400}
height={300}
legendConfig={{ position: LegendPosition.BOTTOM }}
showLegend={false}
showTooltip={false}
plotRef={plotRef}
/>,
);
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
});
});

View File

@@ -1,14 +1,19 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
import {
ScatterTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import uPlot from 'uplot';
import { ScatterChartProps } from 'lib/visualization/charts/types';
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import { getCursorHit } from './utils';
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
// mount; empty aligned data makes the shell show its no-data state instead.
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
@@ -20,9 +25,39 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
channels,
resolvePointLabels,
pinnedTooltipElement,
onPointClick,
plotRef,
...rest
} = props;
const plotInstanceRef = useRef<uPlot | null>(null);
const handlePlotRef = useCallback(
(plot: uPlot | null): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
},
[plotRef],
);
// The shared click data finds its series by x position, which a faceted plot
// has no single axis for; the hit comes from the cursor instead.
const handleClick = useCallback(
(click: ChartClickData): void => {
const plot = plotInstanceRef.current;
const hit = plot ? getCursorHit(plot) : null;
if (!plot || !hit || !onPointClick) {
return;
}
onPointClick({
...hit,
color: getSeriesStroke(plot, hit.seriesIndex),
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
});
},
[onPointClick],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
@@ -56,6 +91,8 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
<ChartWrapper
{...rest}
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
plotRef={handlePlotRef}
onClick={onPointClick ? handleClick : rest.onClick}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>

View File

@@ -0,0 +1,97 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { createScatterRange, type ScatterRangeOptions } from '../scatterRange';
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
function rangeOf(
options: Partial<ScatterRangeOptions>,
dataMin: number | null,
dataMax: number | null,
): [number, number] {
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
...options,
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
return range(plot, dataMin, dataMax);
}
describe('createScatterRange', () => {
beforeAll(() => {
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
});
it('widens a linear range so the data sits marginPx inside each edge', () => {
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
});
it('measures the margin along its own axis', () => {
// 400px of data inside 420px.
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
});
it('pads a log range in decades, without snapping to the next power of ten', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.Logarithmic },
1,
10,
);
expect(Math.log10(min)).toBeCloseTo(-0.05);
expect(Math.log10(max)).toBeCloseTo(1.05);
});
it('pads a symmetric log range in its asinh space', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
0,
20,
);
const span = Math.asinh(10);
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
});
it('stretches to the soft limits before padding', () => {
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
-10, 210,
]);
});
it('ignores a soft min at or below zero on a log scale', () => {
const [min] = rangeOf(
{ distribution: DistributionType.Logarithmic, softMin: 0 },
10,
100,
);
expect(min).toBeGreaterThan(0);
});
it('spreads a single value around itself', () => {
const [min, max] = rangeOf({}, 5, 5);
expect(min).toBeLessThan(5);
expect(max).toBeGreaterThan(5);
expect(5 - min).toBeCloseTo(max - 5);
});
it('falls back to a unit range with no data', () => {
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
expect(
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
).toStrictEqual([1, 10]);
});
it('skips the margin on a plot too small to hold it', () => {
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
}) as (u: uPlot, min: number, max: number) => [number, number];
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
});
});

View File

@@ -1,7 +1,9 @@
import type uPlot from 'uplot';
import { DistributionType } from 'lib/uPlotV2/config/types';
import {
buildScatterConfig,
getCursorHit,
prepareScatterChartData,
resolveAxisDistribution,
ScatterAxisScale,
@@ -31,6 +33,30 @@ describe('prepareScatterChartData', () => {
});
});
describe('getCursorHit', () => {
const plotWith = (idxs: Array<number | null>): uPlot =>
({ cursor: { idxs } }) as unknown as uPlot;
it('reads the series the scatter plugin answered for', () => {
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
seriesIndex: 2,
dataIndex: 4,
});
});
it('takes a data index of 0 as a hit', () => {
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
seriesIndex: 1,
dataIndex: 0,
});
});
it('is null off every dot', () => {
expect(getCursorHit(plotWith([null, null]))).toBeNull();
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
});
});
describe('resolveAxisDistribution', () => {
it('is linear when asked, whatever the values', () => {
expect(
@@ -108,6 +134,13 @@ describe('buildScatterConfig', () => {
...overrides,
});
it('draws a line along both plot edges', () => {
const [xAxis, yAxis] = build().getConfig().axes ?? [];
expect(xAxis?.border?.show).toBe(true);
expect(yAxis?.border?.show).toBe(true);
});
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
const config = build({
x: { unit: 'reqps', label: 'Throughput' },

View File

@@ -0,0 +1,108 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import type { AxisDistribution } from './utils';
interface ScaleTransform {
forward: (value: number) => number;
inverse: (position: number) => number;
}
/** The space uPlot lays the scale out in, so a pixel margin is the same size along it. */
function getScaleTransform({
distribution,
asinhThreshold = 1,
}: AxisDistribution): ScaleTransform {
switch (distribution) {
case DistributionType.Logarithmic:
return {
forward: (value): number => Math.log10(value),
inverse: (position): number => 10 ** position,
};
case DistributionType.SymmetricLog:
return {
forward: (value): number => Math.asinh(value / asinhThreshold),
inverse: (position): number => Math.sinh(position) * asinhThreshold,
};
default:
return {
forward: (value): number => value,
inverse: (position): number => position,
};
}
}
/** Half the span a single value spreads to, in transformed units. */
const SINGLE_VALUE_HALF_SPAN = 0.5;
/** The data's extent stretched to the soft limits; null with neither. */
function resolveExtent(
dataMin: number | null,
dataMax: number | null,
softMin: number | null | undefined,
softMax: number | null | undefined,
): [number, number] | null {
const lows = [dataMin, softMin].filter(
(value): value is number => value != null && Number.isFinite(value),
);
const highs = [dataMax, softMax].filter(
(value): value is number => value != null && Number.isFinite(value),
);
return lows.length > 0 && highs.length > 0
? [Math.min(...lows), Math.max(...highs)]
: null;
}
export interface ScatterRangeOptions extends AxisDistribution {
/** The axis this scale is laid out along. */
dimension: 'x' | 'y';
/** CSS pixels kept clear at each end so the largest dot draws whole. */
marginPx: number;
softMin?: number | null;
softMax?: number | null;
}
/**
* Fits the scale to the data and then widens it by `marginPx` at each end.
* uPlot clips series to the plot area, so a dot at the extreme value would
* otherwise lose the half that hangs past the edge. Log scales are left
* unsnapped: rounding out to the next power of ten after the margin can add a
* whole empty decade.
*/
export function createScatterRange({
dimension,
marginPx,
softMin,
softMax,
...distribution
}: ScatterRangeOptions): uPlot.Scale.Range {
const transform = getScaleTransform(distribution);
const isLog = distribution.distribution === DistributionType.Logarithmic;
// A log scale cannot place a soft limit at or below zero.
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
if (!extent) {
return isLog ? [1, 10] : [0, 1];
}
let start = transform.forward(extent[0]);
let end = transform.forward(extent[1]);
if (start === end) {
start -= SINGLE_VALUE_HALF_SPAN;
end += SINGLE_VALUE_HALF_SPAN;
}
const plotPx =
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
if (plotPx > 2 * marginPx) {
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
start -= pad;
end += pad;
}
return [transform.inverse(start), transform.inverse(end)];
};
}

View File

@@ -1,5 +1,6 @@
import { PrecisionOption } from 'components/Graph/types';
import {
type AxisProps,
DistributionType,
DrawStyle,
SelectionPreferencesSource,
@@ -17,8 +18,15 @@ import {
ScatterPointSize,
ScatterSeriesData,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
logScaleSplits,
spacedLogLabels,
} from 'lib/uPlotV2/utils/logGridSplits';
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import { createScatterRange } from './scatterRange';
/** Circle outline; the fill carries the colour. */
const POINT_STROKE_WIDTH = 1;
@@ -73,6 +81,23 @@ export interface BuildScatterConfigArgs {
shouldSaveSelectionPreference?: boolean;
}
/**
* The dot under the cursor, read off uPlot's cursor: the scatter plugin's
* `dataIdx` answers only for the hit series, so its index is the one set.
*/
export function getCursorHit(
plot: uPlot,
): { seriesIndex: number; dataIndex: number } | null {
const idxs = plot.cursor.idxs ?? [];
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
const dataIndex = idxs[seriesIndex];
if (dataIndex != null) {
return { seriesIndex, dataIndex };
}
}
return null;
}
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
export function prepareScatterChartData(
series: ScatterSeries[],
@@ -156,6 +181,15 @@ export function resolveAxisDistribution(
}
}
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
function getLogAxisTicks({
distribution,
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
return distribution === DistributionType.Logarithmic
? { splits: logScaleSplits, filter: spacedLogLabels }
: {};
}
export function buildScatterConfig({
id,
series,
@@ -195,11 +229,30 @@ export function buildScatterConfig({
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
: undefined;
// The largest disc drawn, plus its outline, kept clear of each plot edge.
const largestDiameter = series.some((entry) => entry.sizes)
? pointSize.max
: pointSize.fixed;
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
const ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
thresholds,
y.unit,
);
builder.addScale({
scaleKey: 'x',
time: false,
softMin: x.softMin ?? undefined,
softMax: x.softMax ?? undefined,
range: createScatterRange({
dimension: 'x',
marginPx,
softMin: x.softMin,
softMax: x.softMax,
...xDistribution,
}),
...xDistribution,
});
builder.addScale({
@@ -208,6 +261,12 @@ export function buildScatterConfig({
softMin: y.softMin ?? undefined,
softMax: y.softMax ?? undefined,
thresholds: yThresholds,
range: createScatterRange({
dimension: 'y',
marginPx,
...ySoftLimits,
...yDistribution,
}),
...yDistribution,
});
@@ -220,6 +279,7 @@ export function buildScatterConfig({
yAxisUnit: x.unit ?? '',
decimalPrecision,
isLogScale: xDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(xDistribution),
space: X_AXIS_TICK_SPACE_PX,
});
builder.addAxis({
@@ -230,6 +290,7 @@ export function buildScatterConfig({
yAxisUnit: y.unit ?? '',
decimalPrecision,
isLogScale: yDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(yDistribution),
});
series.forEach((entry) => {

View File

@@ -78,6 +78,16 @@ export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
export interface ScatterPointClick {
seriesIndex: number;
dataIndex: number;
/** The series' drawn colour. */
color: string;
/** Viewport coordinates of the click. */
coordinates: { x: number; y: number };
}
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
export interface ScatterChartProps extends ChartWrapperProps {
channels: ScatterChannels;
@@ -85,6 +95,8 @@ export interface ScatterChartProps extends ChartWrapperProps {
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
onPointClick?: (click: ScatterPointClick) => void;
}
/**

View File

@@ -38,6 +38,8 @@ interface ConfigPaneProps {
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Group-by labels of a joined scalar result, for the scatter dimensions editor. */
groupColumns?: string[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
@@ -64,6 +66,7 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -132,6 +135,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -158,6 +162,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,6 +12,7 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -60,6 +61,7 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -71,7 +73,10 @@ function SectionSlot({
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
if (
config.kind === SectionKind.Visualization ||
config.kind === SectionKind.Dimensions
) {
return true;
}
const value = editor?.get(spec);
@@ -125,6 +130,8 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -0,0 +1,61 @@
.chips {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
height: 32px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: var(--font-mono, monospace);
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,68 @@
import cx from 'classnames';
import styles from './ConfigChips.module.scss';
export interface ConfigChipItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigChipsProps<T extends string> {
testId: string;
/** Selected values, kept in `items` order. */
value: T[];
items: ConfigChipItem<T>[];
onChange: (value: T[]) => void;
'aria-label'?: string;
}
/** Multi-choice toggle chips; any number may be on, including none. */
function ConfigChips<T extends string>({
testId,
value,
items,
onChange,
'aria-label': ariaLabel,
}: ConfigChipsProps<T>): JSX.Element {
const toggle = (item: T): void => {
const next = value.includes(item)
? value.filter((selected) => selected !== item)
: [...value, item];
onChange(
items
.map((candidate) => candidate.value)
.filter((candidate) => next.includes(candidate)),
);
};
return (
<fieldset
aria-label={ariaLabel}
data-testid={testId}
className={styles.chips}
>
{items.map((item) => {
const selected = value.includes(item.value);
return (
<label
key={item.value}
data-testid={`${testId}-${item.value}`}
className={cx(styles.chip, { [styles.selected]: selected })}
title={item.label}
>
<input
type="checkbox"
className={styles.input}
checked={selected}
aria-label={item.label}
onChange={(): void => toggle(item.value)}
/>
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</fieldset>
);
}
export default ConfigChips;

View File

@@ -0,0 +1,59 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigChips from '../ConfigChips';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
describe('ConfigChips', () => {
it('marks the selected chips as checked', () => {
render(
<ConfigChips
testId="chips"
value={['b']}
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('checkbox', { name: 'beta' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'alpha' })).not.toBeChecked();
});
it('adds a chip in item order, not click order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith(['a', 'c']);
});
it('removes a selected chip, down to none', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['a']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,23 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// The design-system slider insets its track by half a thumb on each side so the
// fill follows the thumb's centre. Pull that inset back off the row so the track
// lines up with the other controls; the thumb never paints past the track edge.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 56px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,49 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigRangeSlider.module.scss';
interface ConfigRangeSliderProps {
testId: string;
value: [number, number];
min: number;
max: number;
step: number;
/** Renders the current range beside the track (e.g. `4–24 px`). */
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
/** Two-thumb slider for a min/max pair; the thumbs can meet but not cross. */
function ConfigRangeSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigRangeSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
range
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => {
if (Array.isArray(next) && next.length === 2) {
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
}
}}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
</span>
</div>
);
}
export default ConfigRangeSlider;

View File

@@ -0,0 +1,51 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigRangeSlider from '../ConfigRangeSlider';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ConfigRangeSlider', () => {
it('renders a thumb per end and the formatted range', () => {
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
formatValue={([min, max]): string => `${min}–${max} px`}
onChange={jest.fn()}
/>,
);
expect(screen.getAllByRole('slider')).toHaveLength(2);
expect(screen.getByText('4–24 px')).toBeInTheDocument();
});
it('moves one end and reports the pair low to high', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
onChange={onChange}
/>,
);
const [, maxThumb] = screen.getAllByRole('slider');
maxThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
});
});

View File

@@ -88,6 +88,16 @@ export const TILE_DRAWINGS = {
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
scaleAuto: (
<TileSvg tone="robin">
<path d="M4 20 L60 4" strokeDasharray="3 3" />
</TileSvg>
),
scaleSymlog: (
<TileSvg tone="robin">
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg tone="robin">
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />

View File

@@ -13,6 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
/** Group-by labels of the joined scalar result; the colour-by options. */
groupColumns?: string[];
/** Label of the value column a Scatter Plot sizes its dots by; unset when none is. */
sizeColumnLabel?: string;
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -5,6 +5,8 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -25,13 +27,18 @@ import BucketsSection from './sections/BucketsSection/BucketsSection';
import { summarizeBuckets } from './sections/BucketsSection/summary';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
import { DEFAULT_POINTS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import { summarizeDimensions } from './sections/DimensionsSection/summary';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
import { summarizeFormatting } from './sections/FormattingSection/summary';
import LegendSection from './sections/LegendSection/LegendSection';
import { summarizeLegend } from './sections/LegendSection/summary';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import { summarizeScatterAxes } from './sections/ScatterAxesSection/summary';
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
@@ -107,6 +114,22 @@ export const SECTION_REGISTRY: {
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeAxes,
},
// Same `axes` key as above, in Scatter Plot's per-axis shape.
[SectionKind.ScatterAxes]: {
Component: ScatterAxesSection,
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeScatterAxes,
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
summarize: summarizeDimensions,
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>
@@ -121,6 +144,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
renderedDefaults: { points: DEFAULT_POINTS },
summarize: summarizeChartAppearance,
},
[SectionKind.Visualization]: {

View File

@@ -13,6 +13,7 @@ import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -37,8 +38,9 @@ function ChartAppearanceSection({
controls,
onChange,
stepInterval,
sizeColumnLabel,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -120,6 +122,15 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
defaultValue={defaultValue?.points}
sizeColumnLabel={sizeColumnLabel}
onChange={(points): void => onChange({ ...value, points })}
/>
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"

View File

@@ -0,0 +1,87 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
POINT_OPACITY_BOUNDS,
POINT_SIZE_BOUNDS,
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import { createFieldResetter } from '../../utils/changes';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
const OPACITY_STEP = 0.01;
interface PointsFieldsProps {
value: DashboardtypesScatterPlotPointsDTO | undefined;
defaultValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
/** The column dots are sized by; unset draws every dot at one size. */
sizeColumnLabel?: string;
}
function PointsFields({
value,
defaultValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
// The renderer's defaults, so a thumb starts where an unset field draws.
const size = resolvePointSize(value);
return (
<>
{sizeColumnLabel ? (
<ConfigField
label="Point size"
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
{...reset('minSize', 'maxSize')}
>
<ConfigRangeSlider
testId="panel-editor-v2-point-size-range"
value={[size.min, size.max]}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSizeRange}
onChange={([minSize, maxSize]): void =>
onChange({ ...value, minSize, maxSize })
}
/>
</ConfigField>
) : (
<ConfigField
label="Point size"
help="Every dot uses this size. Map a Size column to draw a bubble chart."
{...reset('size')}
>
<ConfigSlider
testId="panel-editor-v2-point-size"
value={size.fixed}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSize}
onChange={(next): void => onChange({ ...value, size: next })}
/>
</ConfigField>
)}
<ConfigField label="Fill opacity" {...reset('opacity')}>
<ConfigSlider
testId="panel-editor-v2-point-opacity"
value={resolvePointOpacity(value)}
min={POINT_OPACITY_BOUNDS.min}
max={POINT_OPACITY_BOUNDS.max}
step={OPACITY_STEP}
formatValue={formatOpacity}
onChange={(opacity): void => onChange({ ...value, opacity })}
/>
</ConfigField>
</>
);
}
export default PointsFields;

View File

@@ -0,0 +1,114 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChartAppearanceSection from '../ChartAppearanceSection';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartAppearanceSection points', () => {
it('offers one point size when nothing sizes the dots', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ points: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size-range'),
).not.toBeInTheDocument();
expect(screen.getByText('6 px')).toBeInTheDocument();
expect(
screen.getByText(
'Every dot uses this size. Map a Size column to draw a bubble chart.',
),
).toBeInTheDocument();
expect(screen.getByText('70%')).toBeInTheDocument();
});
it('offers a min/max range named after the size column when one is bound', () => {
render(
<ChartAppearanceSection
value={{ points: { minSize: 5 } }}
controls={{ points: true }}
sizeColumnLabel="B.count()"
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-point-size-range'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.getByText('5–24 px')).toBeInTheDocument();
expect(
screen.getByText(
"Each dot's area scales with B.count() between min and max.",
),
).toBeInTheDocument();
});
it('steps the fixed size by a whole pixel, keeping the range', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const [sizeThumb] = screen.getAllByRole('slider');
sizeThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
points: { size: 7, minSize: 4, maxSize: 30 },
});
});
it('stops the opacity at 10%', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { opacity: 0.1 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const thumbs = screen.getAllByRole('slider');
const opacityThumb = thumbs[thumbs.length - 1];
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
opacityThumb.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('leaves the points out of a kind that does not declare them', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,25 @@
import { summarizeChartAppearance } from '../summary';
describe('summarizeChartAppearance points', () => {
it('reads one size when nothing sizes the dots', () => {
expect(
summarizeChartAppearance({ points: { size: 8 } }, { points: true }, {}),
).toBe('8 px dots, 70%');
});
it('reads the range when a size column is bound', () => {
expect(
summarizeChartAppearance(
{ points: { minSize: 3, opacity: 0.5 } },
{ points: true },
{ sizeColumnLabel: 'B.count()' },
),
).toBe('3–24 px dots, 50%');
});
it('says nothing about points for a kind without them', () => {
expect(summarizeChartAppearance(undefined, { showPoints: true }, {})).toBe(
'',
);
});
});

View File

@@ -1,15 +1,33 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesScatterPlotPointsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import type {
PanelChartAppearanceSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
function describePoints(
points: DashboardtypesScatterPlotPointsDTO | undefined,
sizeColumnLabel: string | undefined,
): string {
const size = resolvePointSize(points);
const dots = sizeColumnLabel
? `${formatPointSizeRange([size.min, size.max])} dots`
: `${formatPointSize(size.fixed)} dots`;
return `${dots}, ${formatOpacity(resolvePointOpacity(points))}`;
}
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
@@ -25,6 +43,7 @@ function describeLine(value: PanelChartAppearanceSlice): string | undefined {
export function summarizeChartAppearance(
value: PanelChartAppearanceSlice | undefined,
controls: SectionControlsOf<SectionKind.ChartAppearance>,
ctx: SectionEditorContext,
): string {
const appearance = value ?? {};
const fill = FILL_MODE_OPTIONS.find(
@@ -37,5 +56,6 @@ export function summarizeChartAppearance(
controls.fillMode && fill && `${fill.label} fill`,
controls.showPoints && appearance.showPoints && 'points',
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
controls.points && describePoints(appearance.points, ctx.sizeColumnLabel),
]);
}

View File

@@ -12,3 +12,11 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
export function formatPointSize(size: number): string {
return `${size} px`;
}
export function formatPointSizeRange([min, max]: [number, number]): string {
return `${min}–${max} px`;
}

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigChips from '../../controls/ConfigChips/ConfigChips';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import {
buildColorKeyItems,
buildDimensionItems,
getColorByHelp,
UNSET_DIMENSION,
} from './utils';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const VALUE_FIELDS = [
{
dimension: 'x',
label: 'X axis',
help: 'The value that places each dot left to right.',
unsetLabel: 'Auto: first value',
},
{
dimension: 'y',
label: 'Y axis',
help: 'The value that places each dot bottom to top.',
unsetLabel: 'Auto: second value',
},
{
dimension: 'size',
label: 'Size',
help:
'Optional. Bigger values draw bigger dots; a group without one draws at the default size.',
unsetLabel: 'None',
},
] as const;
/**
* Edits the `dimensions` slice of a Scatter Plot: which value column each axis
* and the dot size read, and which group-by keys colour the dots. Options come
* from the preview query's joined result.
*/
function DimensionsSection({
value,
defaultValue,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({ value: column.key, label: column.label })),
[tableColumns],
);
const color = value?.color ?? [];
return (
<>
{VALUE_FIELDS.map(({ dimension, label, help, unsetLabel }) => (
<ConfigField
key={dimension}
label={label}
help={help}
{...reset(dimension)}
>
<ConfigSelect
testId={`panel-editor-v2-dimension-${dimension}`}
value={value?.[dimension] ?? UNSET_DIMENSION}
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
onChange={(next): void => onChange({ ...value, [dimension]: next })}
/>
</ConfigField>
))}
<ConfigField
label="Colour by"
help={getColorByHelp(color, groupColumns)}
{...reset('color')}
>
<ConfigChips
testId="panel-editor-v2-dimension-color"
aria-label="Colour by"
value={color}
items={buildColorKeyItems(groupColumns, color)}
onChange={(next): void => onChange({ ...value, color: next })}
/>
</ConfigField>
</>
);
}
export default DimensionsSection;

View File

@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A.count()', label: 'count()' },
{ key: 'A.p99(duration_nano)', label: 'p99(duration_nano)' },
];
async function pick(dimension: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('DimensionsSection', () => {
it('shows each unset dimension as its default', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto: first value',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-size'),
).toHaveTextContent('None');
expect(
screen.getByText('None selected: colour by every group key.'),
).toBeInTheDocument();
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'p99(duration_nano)');
expect(onChange).toHaveBeenCalledWith({
x: 'A.count()',
y: 'A.p99(duration_nano)',
});
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const { rerender } = render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.pod.name'),
);
expect(onChange).toHaveBeenLastCalledWith({ color: ['k8s.pod.name'] });
rerender(
<DimensionsSection
value={{ color: ['k8s.pod.name'] }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
expect(
screen.getByText('One colour per value of the selected keys.'),
).toBeInTheDocument();
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.namespace.name'),
);
expect(onChange).toHaveBeenLastCalledWith({
color: ['k8s.namespace.name', 'k8s.pod.name'],
});
});
it('unselects the last colour key back to every key', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ color: ['service.name'] }}
onChange={onChange}
groupColumns={['service.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-service.name'),
);
expect(onChange).toHaveBeenCalledWith({ color: [] });
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ color: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-color-host.name'),
).toHaveTextContent('host.name (not in results)');
});
it('asks for a group by when there is nothing to colour by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByText('Group the query by a label to colour dots by it.'),
).toBeInTheDocument();
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ size: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('size', 'None');
expect(onChange).toHaveBeenCalledWith({ size: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'B' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'B (not in results)',
);
});
});

View File

@@ -0,0 +1,23 @@
import type { DashboardtypesScatterPlotDimensionsDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
export function summarizeDimensions(
value: DashboardtypesScatterPlotDimensionsDTO | undefined,
_controls: unknown,
{ tableColumns = [] }: SectionEditorContext,
): string {
const labelOf = (key: string | undefined): string | undefined =>
key
? (tableColumns.find((column) => column.key === key)?.label ?? key)
: undefined;
const x = labelOf(value?.x);
const y = labelOf(value?.y);
const size = labelOf(value?.size);
return joinSummary([
x || y ? `${x ?? 'auto'} vs ${y ?? 'auto'}` : 'Auto',
size && `sized by ${size}`,
(value?.color?.length ?? 0) > 0 && `coloured by ${value?.color?.join(', ')}`,
]);
}

View File

@@ -0,0 +1,46 @@
import type { ConfigChipItem } from '../../controls/ConfigChips/ConfigChips';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/**
* The unset option first, then the result's columns. A bound key the current
* result no longer has stays listed, so the select never shows a bare value.
*/
export function buildDimensionItems(
columns: ConfigSelectItem[],
current: string | undefined,
unsetLabel: string,
): ConfigSelectItem[] {
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
if (current && !columns.some((column) => column.value === current)) {
items.push({ value: current, label: `${current} (not in results)` });
}
return items;
}
/** The result's group-by keys, then any selected key the result no longer has. */
export function buildColorKeyItems(
groupColumns: string[],
selected: string[],
): ConfigChipItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}
export function getColorByHelp(
selected: string[],
groupColumns: string[],
): string {
if (groupColumns.length === 0 && selected.length === 0) {
return 'Group the query by a label to colour dots by it.';
}
return selected.length > 0
? 'One colour per value of the selected keys.'
: 'None selected: colour by every group key.';
}

View File

@@ -0,0 +1,32 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ScatterAxisFields from './ScatterAxisFields';
/** Edits a Scatter Plot's `axes` slice: soft bounds and scale, per axis. */
function ScatterAxesSection({
value,
defaultValue,
onChange,
}: SectionEditorProps<SectionKind.ScatterAxes>): JSX.Element {
return (
<>
<ScatterAxisFields
axis="x"
value={value?.x}
defaultValue={defaultValue?.x}
onChange={(x): void => onChange({ ...value, x })}
/>
<ScatterAxisFields
axis="y"
value={value?.y}
defaultValue={defaultValue?.y}
onChange={(y): void => onChange({ ...value, y })}
/>
</>
);
}
export default ScatterAxesSection;

View File

@@ -0,0 +1,84 @@
import { Input } from 'antd';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import { SCALE_HELP, SCALE_OPTIONS } from './options';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
defaultValue: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
}
function ScatterAxisFields({
axis,
value,
defaultValue,
onChange,
}: ScatterAxisFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const name = axis.toUpperCase();
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
return (
<>
<ConfigField
label={`${name}-axis label`}
help="Drawn along the axis, and names its value in the tooltip."
{...reset('label')}
>
<Input
data-testid={`panel-editor-v2-${axis}-label`}
placeholder="None"
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigField>
<ConfigField
label={`${name}-axis range`}
help="The axis always shows at least this range. Data outside it still stretches the axis."
{...reset('softMin', 'softMax')}
>
<ConfigFieldRow>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-min`}
label="Min"
value={value?.softMin}
onChange={(softMin): void => onChange({ ...value, softMin })}
/>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-max`}
label="Max"
value={value?.softMax}
onChange={(softMax): void => onChange({ ...value, softMax })}
/>
</ConfigFieldRow>
</ConfigField>
<ConfigField
label={`${name}-axis scale`}
help={SCALE_HELP[scale]}
{...reset('scale')}
>
<ConfigTiles
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</ConfigField>
</>
);
}
export default ScatterAxisFields;

View File

@@ -0,0 +1,94 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import ScatterAxesSection from '../ScatterAxesSection';
describe('ScatterAxesSection', () => {
it('renders bounds and scale for both axes', () => {
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
['x', 'y'].forEach((axis) => {
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
).toBeInTheDocument();
});
});
it('writes a soft bound to its own axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
expect(onChange).toHaveBeenCalledWith({
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { softMax: 5 },
});
});
it('writes an axis label', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
);
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
});
it.each([
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
])('sets the x scale to %s', async (scale) => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
});
it('marks a changed scale against the default and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
defaultValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesAxisScaleDTO.auto },
});
});
});

View File

@@ -0,0 +1,37 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const SCALE_OPTIONS: ConfigTileItem<DashboardtypesAxisScaleDTO>[] = [
{
value: DashboardtypesAxisScaleDTO.auto,
label: 'Auto',
drawing: TILE_DRAWINGS.scaleAuto,
},
{
value: DashboardtypesAxisScaleDTO.linear,
label: 'Linear',
drawing: TILE_DRAWINGS.scaleLinear,
},
{
value: DashboardtypesAxisScaleDTO.log,
label: 'Logarithmic',
drawing: TILE_DRAWINGS.scaleLog,
},
{
value: DashboardtypesAxisScaleDTO.symlog,
label: 'Symlog',
drawing: TILE_DRAWINGS.scaleSymlog,
},
];
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
[DashboardtypesAxisScaleDTO.auto]:
'Logarithmic when every value is positive and they span three or more orders of magnitude, otherwise linear.',
[DashboardtypesAxisScaleDTO.linear]: 'Evenly spaced values.',
[DashboardtypesAxisScaleDTO.log]:
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
[DashboardtypesAxisScaleDTO.symlog]:
'Logarithmic, but places zero and negative values too.',
};

View File

@@ -0,0 +1,24 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { joinSummary } from '../../utils/summary';
import { SCALE_OPTIONS } from './options';
function scaleLabel(scale: DashboardtypesAxisScaleDTO | undefined): string {
return (
SCALE_OPTIONS.find(
(option) => option.value === (scale ?? DashboardtypesAxisScaleDTO.auto),
)?.label ?? ''
);
}
export function summarizeScatterAxes(
value: DashboardtypesScatterPlotAxesDTO | undefined,
): string {
return joinSummary([
`X ${scaleLabel(value?.x?.scale)}`,
`Y ${scaleLabel(value?.y?.scale)}`,
]);
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getSizeColumnLabel } from '../sizeColumnLabel';
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { size } } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
const COLUMNS = [{ key: 'B', label: 'B.count()' }];
describe('getSizeColumnLabel', () => {
it('names the bound column by its label', () => {
expect(getSizeColumnLabel(scatterSpec('B'), COLUMNS)).toBe('B.count()');
});
it('is unset when nothing is bound', () => {
expect(getSizeColumnLabel(scatterSpec(''), COLUMNS)).toBeUndefined();
expect(getSizeColumnLabel(scatterSpec(), COLUMNS)).toBeUndefined();
});
it('is unset for a bound key the loaded result no longer has, as the chart draws it', () => {
expect(getSizeColumnLabel(scatterSpec('C'), COLUMNS)).toBeUndefined();
});
it('falls back to the key before the result loads', () => {
expect(getSizeColumnLabel(scatterSpec('B'), [])).toBe('B');
});
it('is unset for other kinds', () => {
const spec = {
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
expect(getSizeColumnLabel(spec, COLUMNS)).toBeUndefined();
});
});

View File

@@ -0,0 +1,24 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* What the dots are sized by, mirroring the renderer: a bound key the loaded
* result no longer has sizes nothing. Before the result loads the key stands in.
*/
export function getSizeColumnLabel(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): string | undefined {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return undefined;
}
const key = spec.plugin.spec.dimensions?.size;
if (!key) {
return undefined;
}
if (tableColumns.length === 0) {
return key;
}
return tableColumns.find((column) => column.key === key)?.label;
}

View File

@@ -35,6 +35,7 @@ import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useGroupColumns } from './hooks/useGroupColumns';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
@@ -196,6 +197,7 @@ function QueryEditorBody({
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
const groupColumns = useGroupColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
@@ -325,6 +327,7 @@ function QueryEditorBody({
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useGroupColumns } from '../useGroupColumns';
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind, spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
}
describe('useGroupColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a kind that does not join its scalar rows', () => {
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/PieChartPanel'), DATA),
);
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('keeps only the group-by columns, by key', () => {
mockPrepareScalarTables.mockReturnValue([
{ columns: [], rows: [] },
{
columns: [
{
id: 'k8s.namespace.name',
name: 'k8s.namespace.name',
isValueColumn: false,
},
{ id: 'A', name: 'cpu', isValueColumn: true },
{ id: 'k8s.pod.name', name: 'k8s.pod.name', isValueColumn: false },
],
rows: [],
},
]);
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
);
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
});
});

View File

@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -47,6 +48,7 @@ const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
@@ -60,6 +62,14 @@ const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const scatterPanel = (color?: string[]): DashboardtypesPanelDTO =>
({
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { color } } },
queries: [],
},
}) as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -133,6 +143,36 @@ describe('useLegendSeries', () => {
expect(mockFlatten).not.toHaveBeenCalled();
});
it('resolves scatter plot panels by the colour-by label', () => {
mockPrepareScalarTables.mockReturnValue([
{
queryName: 'A',
legend: '',
columns: [
{ id: 'ns', name: 'ns', queryName: '', isValueColumn: false },
{ id: 'pod', name: 'pod', queryName: '', isValueColumn: false },
{ id: 'A', name: 'A', queryName: 'A', isValueColumn: true },
{ id: 'B', name: 'B', queryName: 'B', isValueColumn: true },
],
rows: [
{ data: { ns: 'prod', pod: 'p1', A: 1, B: 2 } },
{ data: { ns: 'dev', pod: 'p2', A: 3, B: 4 } },
{ data: { ns: 'prod', pod: 'p3', A: 5, B: 6 } },
],
},
]);
const { result } = renderHook(() =>
useLegendSeries(scatterPanel(['ns']), DATA),
);
expect(result.current).toStrictEqual([
{ label: 'prod', defaultColor: 'color:prod' },
{ label: 'dev', defaultColor: 'color:dev' },
]);
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -56,6 +56,23 @@ describe('useTableColumns', () => {
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('resolves columns for a scatter plot, which joins its rows like a table', () => {
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
const panel = {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useTableColumns(panel, DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
]);
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
/**
* Group-by labels of a joined scalar result, the keys a group column is stored
* under. Empty for kinds that don't join their scalar rows or before data arrives.
*/
export function useGroupColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): string[] {
return useMemo(() => {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
return (table?.columns ?? [])
.filter((column) => !column.isValueColumn)
.map((column) => column.id);
}, [
panel.spec.plugin.kind,
data.response,
data.legendMap,
data.requestPayload,
]);
}

View File

@@ -21,7 +21,12 @@ export function useLegendSeries(
return useMemo(() => {
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
? resolve({
spec: panel.spec,
queries: panel.spec.queries,
data,
isDarkMode,
})
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
}, [kind, panel.spec, data, isDarkMode]);
}

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelFormattingSlice } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getColumnUnit } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getColumnUnit';
import { getValueColumnLabels } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getValueColumnLabels';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -28,14 +30,18 @@ export interface TableColumnOption {
* Resolves a Table panel's value (aggregation) columns into `{ key, label }`
* options, so the table-only config editors (column units, per-column thresholds)
* store the query-keyed value the renderer looks up by while showing the readable
* column name. Empty for non-table kinds or before data arrives.
* column name. Empty for kinds that don't join their scalar rows (Table and
* Scatter Plot do) or before data arrives.
*/
export function useTableColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): TableColumnOption[] {
return useMemo(() => {
if (panel.spec.plugin.kind !== 'signoz/TablePanel') {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
@@ -49,13 +55,14 @@ export function useTableColumns(
const columnUnits =
(panel.spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting
?.columnUnits ?? {};
const labels = getValueColumnLabels(table.columns);
return table.columns
.filter((column) => column.isValueColumn)
.map((column) => {
const key = column.id || column.name;
return {
key,
label: column.name,
label: labels[key] ?? column.name,
unit: getColumnUnit(key, columnUnits),
};
});

View File

@@ -33,6 +33,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
@@ -48,6 +49,7 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
'signoz/TextPanel': [],
'signoz/ScatterPlotPanel': [metrics, logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
@@ -100,7 +102,7 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
// Only Table and Scatter Plot ask the server to join their scalar results into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
@@ -108,6 +110,13 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/ScatterPlotPanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,

View File

@@ -0,0 +1,5 @@
.chart {
flex: 1;
min-width: 0;
min-height: 0;
}

View File

@@ -0,0 +1,232 @@
import { useCallback, useMemo, useRef } from 'react';
import { ChartScatter } from '@signozhq/icons';
import type { DashboardtypesScatterPlotPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import type { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { ScatterPointClick } from 'lib/visualization/charts/types';
import Scatter from 'lib/visualization/charts/Scatter/Scatter';
import {
buildScatterConfig,
prepareScatterChartData,
} from 'lib/visualization/charts/Scatter/utils';
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import NoData from '../../components/NoData/NoData';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
import type { PanelRendererProps } from '../../types/rendererProps';
import { mapThresholds } from '../../utils/baseConfigBuilder';
import { enrichScatterClick } from '../../utils/drilldown/enrichScatterClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import {
resolveSelectionPreferencesSource,
shouldSaveSelectionPreference,
} from '../../utils/selectionPreferences';
import ScatterPlotFooter from './components/ScatterPlotFooter/ScatterPlotFooter';
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from './messages';
import { resolvePointOpacity, resolvePointSize } from './points';
import { prepareScatterPlotData } from './prepareData';
import styles from './Renderer.module.scss';
import { ScatterPlotDataStatus } from './types';
import { toScatterAxisOptions } from './utils';
function ScatterPlotPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
panelMode,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/ScatterPlotPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const spec = useMemo<DashboardtypesScatterPlotPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
// V5 joins every query into one scalar result, so the first non-empty table
// holds every group.
const table = useMemo(
() =>
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
[data.response, data.legendMap, data.requestPayload],
);
const scatterData = useMemo(
() =>
prepareScatterPlotData({
table,
dimensions: spec.dimensions,
axes: spec.axes,
columnUnits: spec.formatting?.columnUnits ?? {},
}),
[table, spec.dimensions, spec.axes, spec.formatting?.columnUnits],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const emptyMessage = getScatterPlotEmptyMessage(scatterData);
const footerText = getScatterPlotFooterText(scatterData);
const readyData =
scatterData.status === ScatterPlotDataStatus.Ready &&
scatterData.drawnGroups > 0
? scatterData
: undefined;
const config = useMemo(
() =>
readyData
? buildScatterConfig({
id: panelId,
series: readyData.series,
isDarkMode,
x: toScatterAxisOptions(spec.axes?.x, readyData.channels.x.unit),
y: toScatterAxisOptions(spec.axes?.y, readyData.channels.y.unit),
pointSize: resolvePointSize(spec.chartAppearance?.points),
fillOpacity: resolvePointOpacity(spec.chartAppearance?.points),
colorMapping: spec.legend?.customColors ?? {},
thresholds: mapThresholds(spec.thresholds),
decimalPrecision,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
shouldSaveSelectionPreference: shouldSaveSelectionPreference(panelMode),
})
: undefined,
[
readyData,
panelId,
isDarkMode,
spec.axes?.x,
spec.axes?.y,
spec.chartAppearance?.points,
spec.legend?.customColors,
spec.thresholds,
decimalPrecision,
panelMode,
],
);
const chartData = useMemo(
() => (readyData ? prepareScatterChartData(readyData.series) : undefined),
[readyData],
);
const resolvePointLabels = useCallback(
(seriesIndex: number, dataIndex: number): ScatterPointLabel[] =>
readyData?.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
[readyData],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const handlePointClick = useCallback(
({ seriesIndex, dataIndex, color, coordinates }: ScatterPointClick): void => {
if (!onClick || !readyData) {
return;
}
const payload = enrichScatterClick({
labels: readyData.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
label: readyData.series[seriesIndex - 1]?.label ?? '',
color,
axisQueries: readyData.axisQueries,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, readyData, builderQueries, data.requestPayload],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={!!enableDrillDown}
/>
),
[panelId, enableDrillDown],
);
return (
<div
data-testid="scatter-plot-panel-renderer"
className={PanelStyles.panelContainer}
>
{!readyData && emptyMessage && (
<PanelMessage
icon={<ChartScatter size={18} />}
title={emptyMessage.title}
description={emptyMessage.description}
data-testid="scatter-plot-empty-message"
/>
)}
{!readyData && !emptyMessage && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{readyData && config && chartData && (
<>
<div ref={graphRef} className={styles.chart}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Scatter
key={panelId}
config={config}
data={chartData}
channels={readyData.channels}
resolvePointLabels={resolvePointLabels}
legendConfig={{ position: legendPosition }}
decimalPrecision={decimalPrecision}
canPinTooltip
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onPointClick={enableDrillDown ? handlePointClick : undefined}
data-testid="scatter-plot-chart"
/>
)}
</div>
{footerText && <ScatterPlotFooter text={footerText} />}
</>
)}
</div>
);
}
export default ScatterPlotPanelRenderer;

View File

@@ -0,0 +1,126 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesScatterPlotPanelSpecDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import type { BaseRendererProps } from '../../../types/rendererProps';
import BaseScatterPlotPanelRenderer from '../Renderer';
const ScatterPlotPanelRenderer =
BaseScatterPlotPanelRenderer as React.FC<BaseRendererProps>;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesScatterPlotPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ScatterPlotPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
/** A joined scalar result: one group column plus one value column per query. */
function dataWith(queryNames: string[], rows: unknown[][]): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: queryNames[0],
columns: [
{ name: 'service.name', queryName: '', columnType: 'group' },
...queryNames.map((queryName) => ({
name: '__result_0',
queryName,
columnType: 'aggregation',
aggregationIndex: 0,
})),
],
data: rows,
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
function renderPanel(data: PanelQueryData): ReturnType<typeof render> {
return render(
<ScatterPlotPanelRenderer
panelId="panel-1"
panel={panelWith({})}
data={data}
isFetching={false}
error={null}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
}
describe('ScatterPlotPanelRenderer', () => {
it('shows the plain empty state when the query returns no groups', () => {
const { getByTestId } = renderPanel(dataWith(['A', 'B'], []));
expect(getByTestId('panel-no-data')).toBeInTheDocument();
});
it('asks for a second value when the query has one value column', () => {
const { getByTestId, getByText } = renderPanel(
dataWith(['A'], [['cart', 120]]),
);
expect(getByTestId('scatter-plot-empty-message')).toBeInTheDocument();
expect(getByText('Add a second aggregation or query')).toBeInTheDocument();
});
it('explains the group by mismatch when no row has both an x and a y', () => {
const { getByText } = renderPanel(
dataWith(['A', 'B'], [['checkout', 45, 'n/a']]),
);
expect(getByText('0 of 1 group plotted')).toBeInTheDocument();
});
it('plots once two value columns line up for a group, and says how many did', () => {
const { getByTestId, queryByTestId } = renderPanel(
dataWith(
['A', 'B'],
[
['cart', 120, 340],
['checkout', 45, 'n/a'],
],
),
);
expect(getByTestId('scatter-plot-panel-renderer')).toBeInTheDocument();
expect(queryByTestId('panel-no-data')).not.toBeInTheDocument();
expect(getByTestId('scatter-plot-footer')).toHaveTextContent(
'Showing 1 of 2 groups',
);
});
it('has no footer when every group plots', () => {
const { queryByTestId } = renderPanel(
dataWith(['A', 'B'], [['cart', 120, 340]]),
);
expect(queryByTestId('scatter-plot-footer')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,109 @@
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from '../messages';
import { type ScatterPlotData, ScatterPlotDataStatus } from '../types';
function ready(counts: {
totalGroups: number;
drawnGroups: number;
missingValueGroups?: number;
nonPositiveOnLogGroups?: number;
}): ScatterPlotData {
return {
status: ScatterPlotDataStatus.Ready,
series: [],
pointLabels: [],
channels: { x: { label: 'x' }, y: { label: 'y' } },
axisQueries: { x: 'A', y: 'A' },
missingValueGroups: 0,
nonPositiveOnLogGroups: 0,
...counts,
};
}
describe('getScatterPlotEmptyMessage', () => {
it('leaves an empty result to the plain no-data state', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 0,
}),
).toBeUndefined();
expect(
getScatterPlotEmptyMessage(ready({ totalGroups: 0, drawnGroups: 0 })),
).toBeUndefined();
});
it('asks for a second value', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 3,
})?.title,
).toBe('Add a second aggregation or query');
});
it('says nothing once a group plots', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 3, drawnGroups: 1, missingValueGroups: 2 }),
),
).toBeUndefined();
});
it('points at the group by when no group has both values', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 52, drawnGroups: 0, missingValueGroups: 52 }),
),
).toStrictEqual({
title: '0 of 52 groups plotted',
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
});
});
it('points at symlog when a log axis hid every group', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 1, drawnGroups: 0, nonPositiveOnLogGroups: 1 }),
),
).toStrictEqual({
title: '0 of 1 group plotted',
description:
"1 group with a value ≤ 0 can't go on a log axis. Switch the axis to symlog to show them.",
});
});
});
describe('getScatterPlotFooterText', () => {
it('is absent when every group plots', () => {
expect(
getScatterPlotFooterText(ready({ totalGroups: 4, drawnGroups: 4 })),
).toBeUndefined();
});
it('counts the groups shown', () => {
expect(
getScatterPlotFooterText(
ready({ totalGroups: 52, drawnGroups: 37, missingValueGroups: 15 }),
),
).toBe('Showing 37 of 52 groups');
});
it('calls out the groups a log axis hid', () => {
expect(
getScatterPlotFooterText(
ready({
totalGroups: 52,
drawnGroups: 37,
missingValueGroups: 12,
nonPositiveOnLogGroups: 3,
}),
),
).toBe(
"Showing 37 of 52 groups · 3 groups with a value ≤ 0 can't go on a log axis",
);
});
});

View File

@@ -0,0 +1,23 @@
import {
DEFAULT_POINTS,
resolvePointOpacity,
resolvePointSize,
} from '../points';
describe('scatter plot points', () => {
it('draws unset points at the defaults', () => {
expect(resolvePointSize(undefined)).toStrictEqual({
fixed: DEFAULT_POINTS.size,
min: DEFAULT_POINTS.minSize,
max: DEFAULT_POINTS.maxSize,
});
expect(resolvePointOpacity(undefined)).toBe(DEFAULT_POINTS.opacity);
});
it('takes each set field and defaults the rest, null included', () => {
expect(
resolvePointSize({ size: 10, minSize: null, maxSize: 30 }),
).toStrictEqual({ fixed: 10, min: DEFAULT_POINTS.minSize, max: 30 });
expect(resolvePointOpacity({ opacity: 0.1 })).toBe(0.1);
});
});

View File

@@ -0,0 +1,405 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
PanelTable,
PanelTableColumn,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { prepareScatterPlotData, UNGROUPED_SERIES_LABEL } from '../prepareData';
import { ScatterPlotDataStatus } from '../types';
const group = (id: string): PanelTableColumn => ({
id,
name: id,
queryName: '',
isValueColumn: false,
});
const value = (id: string, name = id): PanelTableColumn => ({
id,
name,
queryName: id.split('.')[0],
isValueColumn: true,
});
function makeTable(
columns: PanelTableColumn[],
rows: Record<string, unknown>[],
): PanelTable {
return {
queryName: 'A',
legend: '',
columns,
rows: rows.map((data) => ({ data })),
};
}
const tracesTable = makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('A.countIf(has_error = true)', 'errors'),
],
[
{
'service.name': 'cart',
'A.count()': 12000,
'A.p99(duration_nano)': 340,
'A.countIf(has_error = true)': 15,
},
{
'service.name': 'payment',
'A.count()': 8500,
'A.p99(duration_nano)': 900,
'A.countIf(has_error = true)': 420,
},
],
);
describe('prepareScatterPlotData', () => {
it('plots the first two value columns when no dimensions are set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
columnUnits: {},
});
expect(result.status).toBe(ScatterPlotDataStatus.Ready);
if (result.status !== ScatterPlotDataStatus.Ready) {
return;
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
expect(result.series).toStrictEqual([
{ label: 'cart', xs: [12000], ys: [340] },
{ label: 'payment', xs: [8500], ys: [900] },
]);
expect(result.pointLabels).toStrictEqual([
[[{ key: 'service.name', value: 'cart' }]],
[[{ key: 'service.name', value: 'payment' }]],
]);
});
it('binds x, y and size to the configured columns, with their units', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: {
x: 'A.p99(duration_nano)',
y: 'A.count()',
size: 'A.countIf(has_error = true)',
},
columnUnits: { 'A.p99(duration_nano)': 'ns' },
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels).toStrictEqual({
x: { label: 'A.p99(duration_nano)', unit: 'ns' },
y: { label: 'A.count()', unit: undefined },
size: { label: 'A.errors', unit: undefined },
});
expect(result.series[1]).toStrictEqual({
label: 'payment',
xs: [900],
ys: [8500],
sizes: [420],
});
});
it('falls back to the defaults when a dimension names a column that is gone', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'B', y: 'A.avg(duration_nano)', size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
});
it('labels each value column with its query, so shared names read apart', () => {
const result = prepareScatterPlotData({
table: makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('B', 'count()'),
],
[
{
'service.name': 'cart',
'A.count()': 1,
'A.p99(duration_nano)': 2,
B: 3,
},
],
),
dimensions: { size: 'B' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size?.label).toBe('B.count()');
expect(result.axisQueries).toStrictEqual({ x: 'A', y: 'A' });
});
it('names an axis channel by its axis label when one is set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
axes: { x: { label: 'Throughput' }, y: { label: '' } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('Throughput');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('picks a y column other than the bound x', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'A.count()' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('colours by the configured group label and keeps every label for the tooltip', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'dev', 'k8s.pod.name': 'p2', A: 3, B: 4 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p3', A: 5, B: 6 },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { color: ['k8s.namespace.name'] },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual([
'prod',
'dev',
]);
expect(result.series[0]).toMatchObject({ xs: [1, 5], ys: [2, 6] });
expect(result.pointLabels[0][1]).toStrictEqual([
{ key: 'k8s.namespace.name', value: 'prod' },
{ key: 'k8s.pod.name', value: 'p3' },
]);
});
describe('colour keys', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p2', A: 3, B: 4 },
],
);
const seriesLabels = (color: string[] | null | undefined): string[] => {
const result = prepareScatterPlotData({
table,
dimensions: { color },
columnUnits: {},
});
return result.status === ScatterPlotDataStatus.Ready
? result.series.map((series) => series.label)
: [];
};
it.each([
['none selected', []],
['an unset field', undefined],
['null from the wire', null],
])('colours by every group key with %s', (_, color) => {
expect(seriesLabels(color)).toStrictEqual(['prod, p1', 'prod, p2']);
});
it('colours by the combination of the selected keys, in result order', () => {
expect(seriesLabels(['k8s.pod.name', 'k8s.namespace.name'])).toStrictEqual([
'prod, p1',
'prod, p2',
]);
});
it('colours by one key alone', () => {
expect(seriesLabels(['k8s.namespace.name'])).toStrictEqual(['prod']);
});
it('ignores keys the result does not have', () => {
expect(seriesLabels(['host.name', 'k8s.pod.name'])).toStrictEqual([
'p1',
'p2',
]);
expect(seriesLabels(['host.name'])).toStrictEqual(['prod, p1', 'prod, p2']);
});
});
it('draws everything as one series when there is no group by', () => {
const result = prepareScatterPlotData({
table: makeTable([value('A'), value('B')], [{ A: 1, B: 2 }]),
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series).toStrictEqual([
{ label: UNGROUPED_SERIES_LABEL, xs: [1], ys: [2] },
]);
});
it.each([
['a join miss', 'n/a'],
['NaN', 'NaN'],
['Inf', 'Inf'],
['-Inf', '-Inf'],
['an empty cell', undefined],
])('drops a row whose y is %s and counts it', (_, cell) => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'checkout', A: 45, B: cell },
],
);
const result = prepareScatterPlotData({
table,
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.totalGroups).toBe(2);
expect(result.drawnGroups).toBe(1);
});
describe('log axes', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'idle', A: 0, B: 12 },
{ 'service.name': 'refund', A: 4, B: -1 },
],
);
it('drops values ≤ 0 on a log axis and counts them apart from missing values', () => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: {
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { scale: DashboardtypesAxisScaleDTO.log },
},
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.nonPositiveOnLogGroups).toBe(2);
expect(result.missingValueGroups).toBe(0);
});
it.each([
DashboardtypesAxisScaleDTO.symlog,
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
])('keeps values ≤ 0 on a %s axis', (scale) => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: { x: { scale }, y: { scale } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.drawnGroups).toBe(3);
expect(result.nonPositiveOnLogGroups).toBe(0);
});
});
it('draws a row with a missing size at the default size', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B'), value('C')],
[
{ 'service.name': 'search', A: 300, B: 120, C: 'n/a' },
{ 'service.name': 'cart', A: 120, B: 340, C: '0.2' },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.sizes)).toStrictEqual([
[null],
[0.2],
]);
expect(result.drawnGroups).toBe(2);
});
it('asks for a second value when the query has one aggregation', () => {
const result = prepareScatterPlotData({
table: makeTable(
[group('service.name'), value('A')],
[{ 'service.name': 'cart', A: 1 }],
),
dimensions: undefined,
columnUnits: {},
});
expect(result).toStrictEqual({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 1,
});
});
it('asks for a second value when there is no table yet', () => {
expect(
prepareScatterPlotData({
table: undefined,
dimensions: undefined,
columnUnits: {},
}).status,
).toBe(ScatterPlotDataStatus.NeedsSecondValue);
});
});

View File

@@ -0,0 +1,11 @@
.footer {
flex-shrink: 0;
padding: 4px 8px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,15 @@
import styles from './ScatterPlotFooter.module.scss';
interface ScatterPlotFooterProps {
text: string;
}
function ScatterPlotFooter({ text }: ScatterPlotFooterProps): JSX.Element {
return (
<div className={styles.footer} title={text} data-testid="scatter-plot-footer">
{text}
</div>
);
}
export default ScatterPlotFooter;

View File

@@ -0,0 +1,55 @@
import { ChartScatter } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ScatterPlotPanel'> = {
kind: 'signoz/ScatterPlotPanel',
displayName: 'Scatter Plot',
mode: 'query',
icon: ChartScatter,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {
legend_format: {
state: 'hidden',
},
},
// Same request as Table: one joined row per group, so x and y can come from
// different queries.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,
clone: true,
// Every group of the joined table, plotted or not.
download: { csv: true, png: true, svg: true },
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,64 @@
import { type ScatterPlotData, ScatterPlotDataStatus } from './types';
export interface ScatterPlotMessage {
title: string;
description: string;
}
function pluralizeGroups(count: number): string {
return count === 1 ? 'group' : 'groups';
}
function logAxisNote(count: number): string {
return `${count} ${pluralizeGroups(count)} with a value ≤ 0 can't go on a log axis`;
}
/**
* Why a query that returned groups still plots nothing. Undefined when the
* query returned no groups (the plain no-data state) or something plots.
*/
export function getScatterPlotEmptyMessage(
data: ScatterPlotData,
): ScatterPlotMessage | undefined {
if (data.totalGroups === 0) {
return undefined;
}
if (data.status === ScatterPlotDataStatus.NeedsSecondValue) {
return {
title: 'Add a second aggregation or query',
description:
'Each dot needs two values, one for X and one for Y. Add an aggregation to this query, or another query grouped by the same labels.',
};
}
if (data.drawnGroups > 0) {
return undefined;
}
const title = `0 of ${data.totalGroups} ${pluralizeGroups(data.totalGroups)} plotted`;
if (data.missingValueGroups === 0) {
return {
title,
description: `${logAxisNote(data.nonPositiveOnLogGroups)}. Switch the axis to symlog to show them.`,
};
}
return {
title,
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
};
}
/** "Showing N of M groups", with the log-axis share called out. Undefined when every group plots. */
export function getScatterPlotFooterText(
data: ScatterPlotData,
): string | undefined {
if (
data.status !== ScatterPlotDataStatus.Ready ||
data.drawnGroups === data.totalGroups
) {
return undefined;
}
const showing = `Showing ${data.drawnGroups} of ${data.totalGroups} groups`;
return data.nonPositiveOnLogGroups > 0
? `${showing} · ${logAxisNote(data.nonPositiveOnLogGroups)}`
: showing;
}

View File

@@ -0,0 +1,35 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
DEFAULT_SCATTER_POINT_SIZE,
type ScatterPointSize,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
/** Bounds the spec validates point sizes (px) and opacity against. */
export const POINT_SIZE_BOUNDS = { min: 2, max: 40 } as const;
export const POINT_OPACITY_BOUNDS = { min: 0.1, max: 1 } as const;
export const DEFAULT_POINT_OPACITY = 0.7;
/** What an unset `points` draws as, spelled out for the editor's change tracking. */
export const DEFAULT_POINTS: Required<DashboardtypesScatterPlotPointsDTO> = {
size: DEFAULT_SCATTER_POINT_SIZE.fixed,
minSize: DEFAULT_SCATTER_POINT_SIZE.min,
maxSize: DEFAULT_SCATTER_POINT_SIZE.max,
opacity: DEFAULT_POINT_OPACITY,
};
export function resolvePointSize(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): ScatterPointSize {
return {
fixed: points?.size ?? DEFAULT_SCATTER_POINT_SIZE.fixed,
min: points?.minSize ?? DEFAULT_SCATTER_POINT_SIZE.min,
max: points?.maxSize ?? DEFAULT_SCATTER_POINT_SIZE.max,
};
}
export function resolvePointOpacity(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): number {
return points?.opacity ?? DEFAULT_POINT_OPACITY;
}

View File

@@ -0,0 +1,181 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type {
PanelTableColumn,
PanelTableRow,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { getValueColumnLabels } from '../../utils/getValueColumnLabels';
import {
type PrepareScatterPlotDataArgs,
type ScatterPlotData,
ScatterPlotDataStatus,
} from './types';
/** Series label when the query has no group by to colour by. */
export const UNGROUPED_SERIES_LABEL = 'All';
/**
* A finite number, or null. The backend sends NaN/±Inf as the strings
* `"NaN"`/`"Inf"`/`"-Inf"` and fills join misses with `"n/a"`; none of those
* may plot, least of all as 0.
*/
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toLabelValue(value: unknown): string {
return typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
? String(value)
: '';
}
function findColumn(
columns: PanelTableColumn[],
key: string | undefined,
): PanelTableColumn | undefined {
return key ? columns.find((column) => column.id === key) : undefined;
}
function isPlaceable(
value: number,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return axis?.scale !== DashboardtypesAxisScaleDTO.log || value > 0;
}
function getPointLabels(
row: PanelTableRow,
groupColumns: PanelTableColumn[],
): ScatterPointLabel[] {
return groupColumns.map((column) => ({
key: column.name,
value: toLabelValue(row.data[column.id]),
}));
}
/** Joins the values of a dot's colour keys into its series label. */
export const COLOR_LABEL_SEPARATOR = ', ';
/**
* The selected colour keys the result has; every group-by column when none
* is selected or none of the selection is left.
*/
function resolveColorColumns(
groupColumns: PanelTableColumn[],
keys: string[] | null | undefined,
): PanelTableColumn[] {
const selected = groupColumns.filter((column) => keys?.includes(column.id));
return selected.length > 0 ? selected : groupColumns;
}
/**
* One dot per row of the joined scalar table. Unset or stale dimensions fall back
* to the first two value columns and every group-by label.
*/
export function prepareScatterPlotData({
table,
dimensions,
axes,
columnUnits,
}: PrepareScatterPlotDataArgs): ScatterPlotData {
const columns = table?.columns ?? [];
const rows = table?.rows ?? [];
const valueColumns = columns.filter((column) => column.isValueColumn);
const groupColumns = columns.filter((column) => !column.isValueColumn);
const xColumn = findColumn(valueColumns, dimensions?.x) ?? valueColumns[0];
const yColumn =
findColumn(valueColumns, dimensions?.y) ??
valueColumns.find((column) => column !== xColumn);
if (!xColumn || !yColumn) {
return {
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: rows.length,
};
}
const sizeColumn = findColumn(valueColumns, dimensions?.size);
const colorColumns = resolveColorColumns(groupColumns, dimensions?.color);
const seriesByLabel = new Map<
string,
{ series: ScatterSeries; labels: ScatterPointLabel[][] }
>();
let drawnGroups = 0;
let missingValueGroups = 0;
let nonPositiveOnLogGroups = 0;
rows.forEach((row) => {
const x = toFiniteNumber(row.data[xColumn.id]);
const y = toFiniteNumber(row.data[yColumn.id]);
if (x === null || y === null) {
missingValueGroups += 1;
return;
}
if (!isPlaceable(x, axes?.x) || !isPlaceable(y, axes?.y)) {
nonPositiveOnLogGroups += 1;
return;
}
const label =
colorColumns.length > 0
? colorColumns
.map((column) => toLabelValue(row.data[column.id]))
.join(COLOR_LABEL_SEPARATOR)
: UNGROUPED_SERIES_LABEL;
let entry = seriesByLabel.get(label);
if (!entry) {
entry = {
series: { label, xs: [], ys: [], ...(sizeColumn && { sizes: [] }) },
labels: [],
};
seriesByLabel.set(label, entry);
}
entry.series.xs.push(x);
entry.series.ys.push(y);
entry.series.sizes?.push(
sizeColumn ? toFiniteNumber(row.data[sizeColumn.id]) : null,
);
entry.labels.push(getPointLabels(row, groupColumns));
drawnGroups += 1;
});
const entries = [...seriesByLabel.values()];
const labels = getValueColumnLabels(columns);
const toChannel = (
column: PanelTableColumn,
axisLabel?: string,
): { label: string; unit: string | undefined } => ({
label: axisLabel || (labels[column.id] ?? column.name),
unit: getColumnUnit(column.id, columnUnits),
});
return {
status: ScatterPlotDataStatus.Ready,
series: entries.map((entry) => entry.series),
pointLabels: entries.map((entry) => entry.labels),
channels: {
x: toChannel(xColumn, axes?.x?.label),
y: toChannel(yColumn, axes?.y?.label),
...(sizeColumn && { size: toChannel(sizeColumn) }),
},
axisQueries: { x: xColumn.queryName, y: yColumn.queryName },
totalGroups: rows.length,
drawnGroups,
missingValueGroups,
nonPositiveOnLogGroups,
};
}

View File

@@ -0,0 +1,29 @@
import { resolveScatterLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Dimensions },
{ kind: SectionKind.ScatterAxes },
{ kind: SectionKind.ChartAppearance, controls: { points: true } },
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveScatterLegendSeries },
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -0,0 +1,49 @@
import type {
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { PanelTable } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
export interface PrepareScatterPlotDataArgs {
/** The joined scalar table (`formatTableResultForUI`). */
table: PanelTable | undefined;
dimensions: DashboardtypesScatterPlotDimensionsDTO | undefined;
/** A `log` axis drops values ≤ 0, which it cannot place. */
axes?: DashboardtypesScatterPlotAxesDTO;
columnUnits: Record<string, string>;
}
export enum ScatterPlotDataStatus {
Ready = 'ready',
/** Fewer than two value columns: nothing to put on y. */
NeedsSecondValue = 'needsSecondValue',
}
export type ScatterPlotData =
| {
status: ScatterPlotDataStatus.NeedsSecondValue;
totalGroups: number;
}
| {
status: ScatterPlotDataStatus.Ready;
/** One per colour group. */
series: ScatterSeries[];
/** `[seriesIndex][dataIndex]`: the point's group-by labels. */
pointLabels: ScatterPointLabel[][][];
channels: ScatterChannels;
/** The query each axis's column comes from, for drilldown. */
axisQueries: { x: string; y: string };
/** Rows the query returned. */
totalGroups: number;
/** Rows plotted. */
drawnGroups: number;
/** Rows missing a finite x or y, e.g. a group absent from one query. */
missingValueGroups: number;
/** Rows with a value ≤ 0 on a `log` axis. */
nonPositiveOnLogGroups: number;
};

View File

@@ -0,0 +1,28 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
type ScatterAxisOptions,
ScatterAxisScale,
} from 'lib/visualization/charts/Scatter/utils';
const CHART_SCALE: Record<DashboardtypesAxisScaleDTO, ScatterAxisScale> = {
[DashboardtypesAxisScaleDTO.auto]: ScatterAxisScale.Auto,
[DashboardtypesAxisScaleDTO.linear]: ScatterAxisScale.Linear,
[DashboardtypesAxisScaleDTO.log]: ScatterAxisScale.Log,
[DashboardtypesAxisScaleDTO.symlog]: ScatterAxisScale.SymLog,
};
export function toScatterAxisOptions(
axis: DashboardtypesScatterPlotAxisDTO | undefined,
unit: string | undefined,
): ScatterAxisOptions {
return {
label: axis?.label,
unit,
softMin: axis?.softMin,
softMax: axis?.softMax,
scale: axis?.scale ? CHART_SCALE[axis.scale] : ScatterAxisScale.Auto,
};
}

View File

@@ -79,6 +79,29 @@ describe('buildTableCsvRows', () => {
});
});
describe('buildTableCsvRows headers', () => {
it('keeps both of two columns that share a name, under their query labels', () => {
const rows = buildTableCsvRows({
table: {
queryName: 'A',
legend: '',
columns: [
{ name: 'service', queryName: '', isValueColumn: false, id: 'service' },
{ name: 'count()', queryName: 'A', isValueColumn: true, id: 'A.count()' },
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A.p99' },
{ name: 'count()', queryName: 'B', isValueColumn: true, id: 'B' },
],
rows: [{ data: { service: 'cart', 'A.count()': 120, 'A.p99': 4, B: 3 } }],
},
columnUnits: {},
});
expect(rows).toStrictEqual([
{ service: 'cart', 'A.count()': '120', p99: '4', 'B.count()': '3' },
]);
});
});
describe('getTableCsvRows', () => {
const panel = {
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
@@ -96,6 +119,23 @@ describe('getTableCsvRows', () => {
]);
});
it('exports a scatter plot from the same joined table', () => {
mockPrepareScalarTables.mockReturnValue([table]);
const scatter = {
spec: {
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { formatting: { columnUnits: { A: 'ns' } } },
},
},
} as unknown as PanelOfKind<'signoz/ScatterPlotPanel'>;
expect(getTableCsvRows(scatter, data)).toStrictEqual([
{ service: 'frontend', p99: '1234ns' },
{ service: 'cart', p99: '56ns' },
]);
});
it('returns no rows when the response has no table', () => {
mockPrepareScalarTables.mockReturnValue([]);

View File

@@ -25,7 +25,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
// Asks the server to transpose its scalar result into UI rows (as does Scatter Plot).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,

View File

@@ -8,6 +8,7 @@ import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { getValueColumnLabels } from '../../utils/getValueColumnLabels';
import type { PanelOfKind } from '../../types/rendererProps';
import { formatTableCellText } from './tableColumns';
@@ -19,6 +20,26 @@ interface BuildTableCsvRowsArgs {
decimalPrecision?: PrecisionOption;
}
/**
* Column key → CSV header. Rows are keyed by header, so two columns sharing a
* name (`count()` on two queries) would collapse into one; those take their
* query-prefixed label instead.
*/
function getCsvHeaders(table: PanelTable): Record<string, string> {
const nameCounts = new Map<string, number>();
table.columns.forEach((col) =>
nameCounts.set(col.name, (nameCounts.get(col.name) ?? 0) + 1),
);
const labels = getValueColumnLabels(table.columns);
return Object.fromEntries(
table.columns.map((col) => {
const key = col.id || col.name;
const collides = (nameCounts.get(col.name) ?? 0) > 1;
return [key, collides ? (labels[key] ?? col.name) : col.name];
}),
);
}
/**
* Flattens a prepared table into CSV rows keyed by column name, reusing the
* on-screen cell formatting in display column order. Exports the full result
@@ -29,12 +50,13 @@ export function buildTableCsvRows({
columnUnits,
decimalPrecision,
}: BuildTableCsvRowsArgs): Record<string, string>[] {
const headers = getCsvHeaders(table);
return table.rows.map((row) => {
const csvRow: Record<string, string> = {};
table.columns.forEach((col) => {
const key = col.id || col.name;
const unit = getColumnUnit(key, columnUnits);
csvRow[col.name] = formatTableCellText(
csvRow[headers[key]] = formatTableCellText(
col,
row.data[key],
unit,
@@ -50,7 +72,7 @@ export function buildTableCsvRows({
* reusing the on-screen formatting. Returns [] when the response has no table.
*/
export function getTableCsvRows(
panel: PanelOfKind<'signoz/TablePanel'>,
panel: PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
data: PanelQueryData,
): Record<string, string>[] {
const spec = panel.spec.plugin.spec;

View File

@@ -3,6 +3,7 @@ import { definition as BarChart } from './kinds/BarChartPanel/definition';
import { definition as Histogram } from './kinds/HistogramPanel/definition';
import { definition as NumberValue } from './kinds/NumberPanel/definition';
import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as ScatterPlot } from './kinds/ScatterPlotPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
@@ -24,6 +25,7 @@ export const PANELS: PanelRegistry = {
[BarChart.kind]: BarChart,
[AreaChart.kind]: AreaChart,
[PieChart.kind]: PieChart,
[ScatterPlot.kind]: ScatterPlot,
[Histogram.kind]: Histogram,
[List.kind]: List,
[Text.kind]: Text,

View File

@@ -36,6 +36,9 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/ScatterPlotPanel': {
onClick?: (event: DrilldownClickPayload) => void;
};
};
/**

View File

@@ -25,6 +25,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
'signoz/TextPanel': PANEL_TYPES.TEXT,
'signoz/ScatterPlotPanel': PANEL_TYPES.SCATTER,
};
/**

View File

@@ -11,6 +11,9 @@ import type {
DashboardtypesLineStyleDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
DashboardtypesScatterPlotPointsDTO,
DashboardtypesSpanGapsDTO,
DashboardtypesStackModeDTO,
DashboardtypesTableFormattingDTO,
@@ -25,6 +28,7 @@ import {
Antenna,
BarChart,
Columns3,
Crosshair,
Hash,
Link2,
Palette,
@@ -62,6 +66,9 @@ export enum SectionKind {
Columns = 'columns',
TextLayout = 'presentation',
PanelHeader = 'headerOptions',
Dimensions = 'dimensions',
/** Scatter Plot's per-axis `axes` shape; `Axes` is the single y-axis shape. */
ScatterAxes = 'scatterAxes',
}
/**
@@ -101,6 +108,8 @@ export interface PanelChartAppearanceSlice {
fillOpacity?: DashboardtypesFillOpacityDTO;
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
/** Scatter Plot's dot size and opacity. */
points?: DashboardtypesScatterPlotPointsDTO;
}
/** Superset spanning every kind's visualization DTO. */
@@ -127,6 +136,8 @@ export interface SectionSpecMap {
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
[SectionKind.TextLayout]: DashboardtypesTextPresentationDTO; // spec.plugin.spec.presentation (Text)
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
[SectionKind.Dimensions]: DashboardtypesScatterPlotDimensionsDTO; // spec.plugin.spec.dimensions (Scatter Plot)
[SectionKind.ScatterAxes]: DashboardtypesScatterPlotAxesDTO; // spec.plugin.spec.axes (Scatter Plot)
}
/**
@@ -157,6 +168,8 @@ export interface SectionControls {
fillOpacity?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
/** Scatter Plot's dots: one size, or a min/max range when a size column is bound. */
points?: boolean;
};
[SectionKind.Buckets]: {
count?: boolean;
@@ -184,7 +197,9 @@ export type AtomicSectionKind =
| SectionKind.ContextLinks
| SectionKind.Columns
| SectionKind.TextLayout
| SectionKind.PanelHeader;
| SectionKind.PanelHeader
| SectionKind.Dimensions
| SectionKind.ScatterAxes;
/** Predicate to hide a section from the current spec; returning true removes it. */
export type SectionVisibilityPredicate = (
@@ -219,6 +234,8 @@ export const SECTION_METADATA = {
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
[SectionKind.TextLayout]: { title: 'Panel appearance', icon: AlignLeft },
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
[SectionKind.Dimensions]: { title: 'Dimensions', icon: Crosshair },
[SectionKind.ScatterAxes]: { title: 'Axes', icon: Scale3D },
} as const satisfies Record<SectionKind, SectionMetadata>;
/**

View File

@@ -535,6 +535,40 @@ describe('buildPluginSpec', () => {
});
});
it('carries per-column units between kinds that both key them by column (Table ↔ Scatter Plot)', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
const oldSpec = oldSpecWith(
{
formatting: {
columnUnits: { 'A.count()': 'short', 'A.p99(duration_nano)': 'ns' },
decimalPrecision: 1,
},
},
[builderQueryNamed('A')],
);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 1,
columnUnits: { 'A.count()': 'short', 'A.p99(duration_nano)': 'ns' },
});
});
it('keeps an emptied column unit map empty rather than fanning a unit out', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { columnUnits: true } },
];
const oldSpec = oldSpecWith({ formatting: { columnUnits: {} } }, [
builderQueryNamed('A'),
]);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toBeUndefined();
});
it('never seeds a panel-wide unit from per-column units (Table → TimeSeries)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },

View File

@@ -0,0 +1,39 @@
import type { PanelTableColumn } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getValueColumnLabels } from '../getValueColumnLabels';
const column = (
id: string,
name: string,
queryName: string,
isValueColumn = true,
): PanelTableColumn => ({ id, name, queryName, isValueColumn });
describe('getValueColumnLabels', () => {
it('prefixes every value column with its query', () => {
expect(
getValueColumnLabels([
column('service.name', 'service.name', '', false),
column('A.count()', 'count()', 'A'),
column('A.p99(duration_nano)', 'p99(duration_nano)', 'A'),
column('B', 'count()', 'B'),
]),
).toStrictEqual({
'A.count()': 'A.count()',
'A.p99(duration_nano)': 'A.p99(duration_nano)',
B: 'B.count()',
});
});
it('leaves a name that already is the query name bare', () => {
expect(
getValueColumnLabels([column('F1', 'F1', 'F1'), column('A', 'A', 'A')]),
).toStrictEqual({ F1: 'F1', A: 'A' });
});
it('leaves a column with no query name bare', () => {
expect(getValueColumnLabels([column('x', 'x', '')])).toStrictEqual({
x: 'x',
});
});
});

View File

@@ -288,6 +288,13 @@ const SECTION_SEEDS: SectionSeeds = {
decimalPrecision: old.decimalPrecision,
}),
};
// Kinds that both key units per column (Table ↔ Scatter Plot) share the keys.
if (controls.columnUnits && old?.columnUnits) {
if (Object.keys(old.columnUnits).length > 0) {
carried.columnUnits = { ...old.columnUnits };
}
return carried;
}
// A panel-wide unit fans out to every value column when the target keys units
// per column (→ Table). One-way: `columnUnits` never seed a panel-wide `unit`.
const unit = old?.unit;

View File

@@ -0,0 +1,76 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import { enrichScatterClick } from '../enrichScatterClick';
const builderQuery = (spec: Record<string, unknown>): BuilderQuery =>
spec as unknown as BuilderQuery;
const QUERIES = [
builderQuery({
name: 'A',
signal: 'traces',
groupBy: [{ name: 'service.name' }, { name: 'k8s.namespace.name' }],
}),
builderQuery({
name: 'B',
signal: 'logs',
groupBy: [{ name: 'service.name' }],
}),
];
const LABELS = [
{ key: 'service.name', value: 'checkout' },
{ key: 'k8s.namespace.name', value: 'prod' },
];
const click = (
axisQueries: { x: string; y: string },
builderQueries = QUERIES,
): ReturnType<typeof enrichScatterClick> =>
enrichScatterClick({
labels: LABELS,
label: 'checkout, prod',
color: '#4E74F8',
axisQueries,
builderQueries,
coordinates: { x: 7, y: 8 },
timeRange: { startTime: 1, endTime: 2 },
});
describe('enrichScatterClick', () => {
it("follows Y's query and filters to the dot's group", () => {
expect(click({ x: 'B', y: 'A' })).toStrictEqual({
coordinates: { x: 7, y: 8 },
context: {
queryName: 'A',
signal: 'traces',
filters: [
{ filterKey: 'service.name', filterValue: 'checkout', operator: '=' },
{ filterKey: 'k8s.namespace.name', filterValue: 'prod', operator: '=' },
],
timeRange: { startTime: 1, endTime: 2 },
label: 'checkout, prod',
seriesColor: '#4E74F8',
},
});
});
it('keeps only the labels the followed query groups by', () => {
expect(click({ x: 'A', y: 'B' })?.context).toMatchObject({
queryName: 'B',
signal: 'logs',
filters: [
{ filterKey: 'service.name', filterValue: 'checkout', operator: '=' },
],
});
});
it("falls back to X's query when Y's is not a builder query", () => {
expect(click({ x: 'A', y: 'F1' })?.context.queryName).toBe('A');
});
it('returns null when neither axis has a builder query', () => {
expect(click({ x: 'F1', y: 'F2' })).toBeNull();
expect(click({ x: 'A', y: 'A' }, [])).toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichScatterClickArgs {
/** The dot's group-by labels. */
labels: ScatterPointLabel[];
/** The dot's series label, as the legend names it. */
label: string;
color: string;
/** Y's query first: a dot is placed by both, but Y is usually what was measured. */
axisQueries: { x: string; y: string };
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** The panel's fetched window; a dot summarises all of it. */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a dot click into a drilldown payload filtered to the dot's group. The
* drilldown follows Y's query, or X's when Y has no builder query (a formula,
* PromQL). Returns `null` when neither does.
*/
export function enrichScatterClick({
labels,
label,
color,
axisQueries,
builderQueries,
coordinates,
timeRange,
}: EnrichScatterClickArgs): DrilldownClickPayload | null {
const queryName = [axisQueries.y, axisQueries.x].find(
(name) =>
isValidQueryName(name) &&
builderQueries.some((query) => query.name === name),
);
const builderQuery = builderQueries.find((query) => query.name === queryName);
if (!queryName || !builderQuery) {
return null;
}
const groupLabels = Object.fromEntries(
labels.map((point) => [point.key, point.value]),
);
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: getGroupByFilters(groupLabels, builderQuery),
timeRange,
label,
seriesColor: color,
},
};
}

View File

@@ -0,0 +1,20 @@
import type { PanelTableColumn } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
/**
* Value column id → `queryName.name`, so `count()` on two queries reads apart.
* A name that already is the query name (a formula, a PromQL query) stays bare.
*/
export function getValueColumnLabels(
columns: PanelTableColumn[],
): Record<string, string> {
return Object.fromEntries(
columns
.filter((column) => column.isValueColumn)
.map((column) => [
column.id || column.name,
column.queryName && column.name !== column.queryName
? `${column.queryName}.${column.name}`
: column.name,
]),
);
}

View File

@@ -1,8 +1,13 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
import { prepareScatterPlotData } from '../kinds/ScatterPlotPanel/prepareData';
import { ScatterPlotDataStatus } from '../kinds/ScatterPlotPanel/types';
import { getBuilderQueries } from './getBuilderQueries';
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
@@ -23,6 +28,8 @@ export interface LegendSeries {
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
interface LegendSeriesArgs {
/** The kind's own spec, for kinds whose labels depend on it. */
spec: DashboardtypesPanelSpecDTO;
queries: PanelQueries;
data: PanelQueryData;
isDarkMode: boolean;
@@ -103,3 +110,37 @@ export function resolveTimeSeriesLegendSeries({
(label) => generateColor(label, palette),
);
}
/**
* Scatter Plot: one entry per colour group, which `dimensions.color` picks, so the
* labels come from the same data prep the renderer runs.
*/
export function resolveScatterLegendSeries({
spec,
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return [];
}
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const scatterData = prepareScatterPlotData({
table: prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
dimensions: spec.plugin.spec.dimensions,
axes: spec.plugin.spec.axes,
columnUnits: {},
});
if (scatterData.status !== ScatterPlotDataStatus.Ready) {
return [];
}
return buildLegendSeries(
scatterData.series.map((series) => series.label),
(label) => generateColor(label, palette),
);
}

View File

@@ -40,6 +40,11 @@ const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
description: 'Compare values across categories',
},
'signoz/PieChartPanel': { group: 'compare', description: 'Share of a whole' },
'signoz/ScatterPlotPanel': {
group: 'compare',
description: 'Two values per group, plotted against each other',
isNew: true,
},
'signoz/HistogramPanel': {
group: 'distributions',
description: 'Distribution of values into buckets',

View File

@@ -10,6 +10,19 @@ const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
const RING_TRACK = { stroke: 'var(--l3-background)' };
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
const DOT_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.55 };
/** `[cx, cy, r]`: a loose up-and-right cloud with one large outlier. */
const SCATTER_DOTS = [
[14, 34, 2.5],
[26, 28, 3.5],
[38, 31, 2],
[50, 20, 4],
[62, 24, 2.5],
[76, 14, 3],
[88, 18, 2],
[102, 8, 6],
];
const LINE_PATH =
'M0 34 12 26 24 30 36 16 48 22 60 10 72 18 84 8 96 14 108 5 120 11';
@@ -73,6 +86,13 @@ export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
className={styles.histogram}
/>
),
'signoz/ScatterPlotPanel': (
<svg viewBox="0 0 120 44" className={styles.svg}>
{SCATTER_DOTS.map(([cx, cy, r]) => (
<circle key={cx} cx={cx} cy={cy} r={r} style={DOT_FILL} />
))}
</svg>
),
'signoz/PieChartPanel': (
<svg viewBox="0 0 44 44" className={styles.svg}>
<circle

View File

@@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useDrilldownBreakout } from '../useDrilldownBreakout';
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('container/QueryTable/Drilldown/tableDrilldownUtils', () => ({
...jest.requireActual('container/QueryTable/Drilldown/tableDrilldownUtils'),
getBreakoutQuery: jest.fn((query: Query) => query),
}));
const QUERY = {
queryType: 'builder',
builder: { queryData: [{ queryName: 'A' }], queryFormulas: [] },
} as unknown as Query;
const AGGREGATE = { queryName: 'A', filters: [] } as unknown as AggregateData;
function breakOutFrom(panelType: PANEL_TYPES): PANEL_TYPES {
const openViewWithQuery = jest.fn();
const { result } = renderHook(() =>
useDrilldownBreakout({
panelId: 'p1',
v1Query: QUERY,
panelType,
aggregateData: AGGREGATE,
openViewWithQuery,
onClose: jest.fn(),
}),
);
result.current.onBreakout({ name: 'host.name' } as never);
return openViewWithQuery.mock.calls[0][2];
}
describe('useDrilldownBreakout', () => {
it('opens a scatter plot breakout as a table', () => {
expect(breakOutFrom(PANEL_TYPES.SCATTER)).toBe(PANEL_TYPES.TABLE);
});
it('keeps the panel type for the kinds V1 breaks out in place', () => {
expect(breakOutFrom(PANEL_TYPES.TIME_SERIES)).toBe(PANEL_TYPES.TIME_SERIES);
expect(breakOutFrom(PANEL_TYPES.VALUE)).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -13,9 +13,10 @@ interface UseDownloadPanelCsvArgs {
data: PanelQueryData;
/**
* Whether the kind's definition declares CSV as a downloadable format
* (`actions.download.csv`). Only tables carry tabular data, so this is the
* same gate the menu uses — kept here so the callback stays a no-op when
* invoked for a kind that can't produce CSV.
* (`actions.download.csv`). Only kinds drawn from a joined scalar table
* (Table, Scatter Plot) carry tabular data, so this is the same gate the menu
* uses — kept here so the callback stays a no-op when invoked for a kind that
* can't produce CSV.
*/
canDownloadCsv: boolean;
}
@@ -35,7 +36,10 @@ export function useDownloadPanelCsv({
if (!canDownloadCsv) {
return;
}
const rows = getTableCsvRows(panel as PanelOfKind<'signoz/TablePanel'>, data);
const rows = getTableCsvRows(
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
data,
);
if (rows.length === 0) {
return;
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
getBreakoutPanelType,
@@ -66,7 +66,13 @@ export function useDrilldownBreakout({
groupBy,
aggregateData.filters ?? [],
);
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
// A breakout regroups one query, which would stop a scatter plot's queries
// joining on the same labels; a table shows the regrouped query on its own.
const breakoutPanelType =
panelType === PANEL_TYPES.SCATTER
? PANEL_TYPES.TABLE
: getBreakoutPanelType(panelType);
openViewWithQuery(panelId, breakoutQuery, breakoutPanelType);
onClose();
},
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],

View File

@@ -5,7 +5,7 @@ import type {
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
envelopesToQuery,
@@ -123,6 +123,35 @@ describe('persesQueryAdapters', () => {
expect(result[0].kind).toBe('scalar');
});
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER])(
'sends a %s panel metric query as scalar with its reduceTo, which the API requires',
(panelType) => {
const metrics = initialQueriesMap[DataSource.METRICS];
const query: Query = {
...metrics,
builder: {
...metrics.builder,
queryData: metrics.builder.queryData.map((queryData) => ({
...queryData,
// `aggregations` is a union of arrays, which `map` widens.
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
reduceTo: ReduceOperators.SUM,
})) as typeof queryData.aggregations,
})),
},
};
const [envelope] = toPerses(query, panelType);
const composite = envelope.spec.plugin.spec as {
queries: { spec: { aggregations?: { reduceTo?: string }[] } }[];
};
expect(envelope.kind).toBe('scalar');
expect(composite.queries[0].spec.aggregations?.[0]?.reduceTo).toBe('sum');
},
);
it('emits a bare signoz/BuilderQuery for a List panel (not a CompositeQuery)', () => {
const result = toPerses(
initialQueriesMap[DataSource.LOGS],

View File

@@ -106,6 +106,7 @@ export function panelTypeToRequestType(
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
case PANEL_TYPES.SCATTER:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;

View File

@@ -17,6 +17,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
// Dashboard-only visualisation; explorers never offer it.
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
[PANEL_TYPES.SCATTER]: ExplorerViews.TABLE,
[PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST,
};