Compare commits

...

3 Commits

Author SHA1 Message Date
Abhi Kumar
9cd6aa7e40 test(dashboards): type scatter prune fixtures 2026-10-30 17:25:31 +05:30
Abhi Kumar
fa3d01e0fc fix(dashboards): drop stale scatter bindings on save
Bindings the edited query no longer produces are cleared so the save matches the
preview's fallback columns instead of failing API validation.
2026-10-30 17:15:59 +05:30
Abhi Kumar
61a9c1dba1 fix(dashboards): compare scatter point size bounds at their defaults 2026-10-30 17:15:58 +05:30
7 changed files with 230 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
getSupportedSignals,
@@ -104,7 +105,11 @@ export function usePanelEditSession({
enabled: isPanelKindSupported(panelKind),
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
const {
runQuery,
isQueryDirty,
buildSaveSpec: buildQuerySaveSpec,
} = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
@@ -114,6 +119,15 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { prepareSaveSpec } = panelDefinition;
const buildSaveSpec = useCallback(
(next: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO => {
const withQueries = buildQuerySaveSpec(next);
return prepareSaveSpec ? prepareSaveSpec(withQueries) : withQueries;
},
[buildQuerySaveSpec, prepareSaveSpec],
);
return {
draft,
spec,

View File

@@ -7,6 +7,7 @@ import Renderer from './Renderer';
import { MAX_PLOTTED_GROUPS } from './utils/prepareData';
import { sections } from './sections';
import { getScatterPlotDataWarning } from './utils/warning';
import { pruneStaleDimensions } from './utils/pruneStaleDimensions';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
@@ -57,6 +58,7 @@ export const definition: PanelDefinition<'signoz/ScatterPlotPanel'> = {
// One group by everywhere, so the queries' results join row for row.
syncedQueryBuilderFields: [QueryBuilderField.GroupBy],
getDataWarning: getScatterPlotDataWarning,
prepareSaveSpec: pruneStaleDimensions,
actions: {
view: true,
edit: true,

View File

@@ -0,0 +1,120 @@
import {
type DashboardtypesPanelPluginDTO,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind as ScatterPlotKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTOKind as TableKind,
type DashboardtypesPanelSpecDTO,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryKind,
type DashboardtypesScatterPlotDimensionsDTO,
type DashboardtypesScatterPlotPanelSpecDTO,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal as LogsSignal,
type Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { pruneStaleDimensions } from '../pruneStaleDimensions';
const scatterPlugin = (
dimensions: DashboardtypesScatterPlotDimensionsDTO,
): DashboardtypesPanelPluginDTO => ({
kind: ScatterPlotKind['signoz/ScatterPlotPanel'],
spec: { dimensions },
});
function panelSpec(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
plugin: DashboardtypesPanelPluginDTO,
): DashboardtypesPanelSpecDTO {
return {
display: { name: 'p' },
plugin,
queries: [
{
kind: Querybuildertypesv5RequestTypeDTO.scalar,
spec: {
plugin: {
kind: CompositeQueryKind['signoz/CompositeQuery'],
spec: { queries: envelopes },
},
},
},
],
};
}
const builderQuery = (
name: string,
expressions: string[],
groupBy: string[] = [],
): Querybuildertypesv5QueryEnvelopeDTO => ({
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: {
name,
signal: LogsSignal.logs,
aggregations: expressions.map((expression) => ({ expression })),
groupBy: groupBy.map((label) => ({ name: label })),
},
});
const dimensionsOf = (
spec: DashboardtypesPanelSpecDTO,
): DashboardtypesScatterPlotDimensionsDTO | undefined =>
(spec.plugin.spec as DashboardtypesScatterPlotPanelSpecDTO).dimensions;
describe('pruneStaleDimensions', () => {
it('keeps the spec when every binding is a column of the queries', () => {
const spec = panelSpec(
[
builderQuery('A', ['count()', 'avg(duration)'], ['service.name']),
builderQuery('B', ['count()'], ['service.name']),
],
scatterPlugin({
x: 'A.count()',
y: 'B',
sizeBy: 'A.avg(duration)',
colorBy: ['service.name'],
}),
);
expect(pruneStaleDimensions(spec)).toBe(spec);
});
it('clears value bindings and drops color labels the queries no longer produce', () => {
const spec = panelSpec(
[builderQuery('A', ['count()'], ['host.name'])],
scatterPlugin({
x: 'A.count()',
y: 'A',
sizeBy: 'B',
colorBy: ['service.name', 'host.name'],
}),
);
expect(dimensionsOf(pruneStaleDimensions(spec))).toStrictEqual({
x: '',
y: 'A',
sizeBy: '',
colorBy: ['host.name'],
});
});
it('keeps bindings when a query has columns known only at run time', () => {
const spec = panelSpec(
[
{
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql,
spec: { name: 'A', query: 'SELECT 1' },
},
],
scatterPlugin({ x: 'value', y: 'other' }),
);
expect(pruneStaleDimensions(spec)).toBe(spec);
});
it('leaves other panel kinds untouched', () => {
const spec = panelSpec([builderQuery('A', ['count()'])], {
kind: TableKind['signoz/TablePanel'],
spec: {},
});
expect(pruneStaleDimensions(spec)).toBe(spec);
});
});

View File

@@ -0,0 +1,64 @@
import {
type DashboardtypesPanelSpecDTO,
type DashboardtypesScatterPlotPanelSpecDTO,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { toQueryEnvelopes } from '../../../../queryV5/buildQueryRangeRequest';
import { getBuilderQueries } from '../../../utils/getBuilderQueries';
import { getTableColumnKeys } from '../../../utils/getTableColumnKeys';
/**
* Clears bindings to columns the queries no longer produce, so a query edit
* saves what the preview drew (its fallback columns) instead of a binding the
* API rejects. ClickHouse SQL and PromQL columns are only known at run time.
*/
export function pruneStaleDimensions(
spec: DashboardtypesPanelSpecDTO,
): DashboardtypesPanelSpecDTO {
const { plugin, queries } = spec;
if (plugin.kind !== 'signoz/ScatterPlotPanel') {
return spec;
}
const hasRuntimeColumns = toQueryEnvelopes(queries).some(
(envelope) =>
envelope.type ===
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql ||
envelope.type === Querybuildertypesv5QueryEnvelopePromQLDTOType.promql,
);
if (hasRuntimeColumns) {
return spec;
}
const pluginSpec = plugin.spec as DashboardtypesScatterPlotPanelSpecDTO;
const dimensions = pluginSpec.dimensions ?? {};
const valueKeys = new Set(getTableColumnKeys(queries));
const groupKeys = new Set(
getBuilderQueries(queries).flatMap((query) =>
(query.groupBy ?? []).map((key) => key.name),
),
);
const keepValue = (key: string | undefined): string | undefined =>
key && !valueKeys.has(key) ? '' : key;
const next = {
...dimensions,
x: keepValue(dimensions.x),
y: keepValue(dimensions.y),
sizeBy: keepValue(dimensions.sizeBy),
colorBy: dimensions.colorBy?.filter((label) => groupKeys.has(label)),
};
const isUnchanged =
next.x === dimensions.x &&
next.y === dimensions.y &&
next.sizeBy === dimensions.sizeBy &&
next.colorBy?.length === dimensions.colorBy?.length;
if (isUnchanged) {
return spec;
}
return {
...spec,
plugin: { ...plugin, spec: { ...pluginSpec, dimensions: next } },
};
}

View File

@@ -133,6 +133,10 @@ export interface QueryPanelDefinition<
panel: PanelOfKind<K>,
data: PanelQueryData,
) => PanelStatusDetail | null;
/** Reconciles the kind's options with the queries a save carries. */
prepareSaveSpec?: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**

View File

@@ -2032,6 +2032,18 @@ func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "max point size below the default min",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 2}}}`,
expectedErrorSubstring: "minSize 4 must not exceed maxSize 2",
},
{
scenario: "min point size above the default max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 40}}}`,
expectedErrorSubstring: "minSize 40 must not exceed maxSize 24",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",

View File

@@ -480,9 +480,17 @@ func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
return p.validate()
}
// Null bounds are compared at their renderer defaults, so one-sided settings can't invert the range.
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
minSize, maxSize := defaultPointMinSize, defaultPointMaxSize
if p.MinSize != nil {
minSize = *p.MinSize
}
if p.MaxSize != nil {
maxSize = *p.MaxSize
}
if minSize > maxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", minSize, maxSize)
}
return nil
}
@@ -1079,6 +1087,9 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
const (
minPointDiameter = 2
maxPointDiameter = 40
defaultPointMinSize PointDiameter = 4
defaultPointMaxSize PointDiameter = 24
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.