mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-02 06:21:05 +00:00
Compare commits
3 Commits
feat/scatt
...
fix/scatte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd6aa7e40 | ||
|
|
fa3d01e0fc | ||
|
|
61a9c1dba1 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1906,13 +1906,13 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
name: "SingleAggregation_KeyedByQueryName",
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
@@ -1922,7 +1922,7 @@ func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
name: "SeveralAggregations_KeyedByQueryNameAndExpression",
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
@@ -1931,7 +1931,7 @@ func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
name: "SeveralMetricAggregations_KeyedByQueryName",
|
||||
scenario: "several metric aggregations are keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}, {MetricName: "http.server.request.duration"}},
|
||||
@@ -1940,7 +1940,7 @@ func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
name: "DisabledQuery_NoAggregation_GroupsKeptForFormula",
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
@@ -1954,7 +1954,7 @@ func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
name: "ClickHouseQuery_ColumnsUnknownUntilRunTime",
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
@@ -1965,7 +1965,7 @@ func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
|
||||
Reference in New Issue
Block a user