Compare commits

...

4 Commits

Author SHA1 Message Date
Abhi Kumar
228dc97b44 fix(sqlmigration): keep the factory name within the 31-char limit
factory.MustNewName panics above 31 characters, taking down provider
construction at startup.

Assisted-by: Claude Opus 5
2026-09-10 15:55:12 +05:30
Abhi Kumar
939a39d13c fix(charts): honour an explicit soft min of 0 on the y axis 2026-09-10 15:35:42 +05:30
Abhi Kumar
bca4a5e5dc fix(charts): drop v1's default soft bounds before they reach the chart layer
The v1 render path shares the v2 scale builder, so the 0/0 default has to be
stripped on the way in before that builder starts honouring an explicit 0.
2026-09-10 15:35:25 +05:30
Abhi Kumar
727e4ffc18 fix(dashboard): stop carrying v1's default axes soft bounds into v2
v1 seeded softMin/softMax to 0/0 on every new widget, so that pair means
"unset" rather than a bound. Carrying it into v2 forced the chart layer to
ignore it, which is what left a deliberate soft min of 0 unsettable. Cleared
both at conversion and, for the dashboards migration 103 already rewrote, in
a migration of its own.
2026-09-10 15:35:18 +05:30
13 changed files with 336 additions and 14 deletions

View File

@@ -13,6 +13,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { getWidgetSoftBounds } from '../utils';
export function prepareBarPanelConfig({
widget,
@@ -48,8 +49,7 @@ export function prepareBarPanelConfig({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
...getWidgetSoftBounds(widget),
isLogScale: widget.isLogScale,
isDarkMode,
onClick,

View File

@@ -18,6 +18,7 @@ import {
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'lib/visualization/panels/utils/histogram';
import { getWidgetSoftBounds } from '../utils';
export interface PrepareHistogramPanelDataParams {
apiResponse: MetricRangePayloadProps;
@@ -157,8 +158,7 @@ export function prepareHistogramPanelConfig({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
...getWidgetSoftBounds(widget),
isLogScale: widget.isLogScale,
isDarkMode,
apiResponse,

View File

@@ -19,6 +19,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { getWidgetSoftBounds } from '../utils';
export const prepareUPlotConfig = ({
widget,
@@ -55,8 +56,7 @@ export const prepareUPlotConfig = ({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
...getWidgetSoftBounds(widget),
isLogScale: widget.isLogScale,
isDarkMode,
onClick,

View File

@@ -0,0 +1,29 @@
import { getWidgetSoftBounds } from '../utils';
describe('getWidgetSoftBounds', () => {
it("drops v1's 0/0 unset default", () => {
expect(getWidgetSoftBounds({ softMin: 0, softMax: 0 })).toStrictEqual({});
});
it('keeps a lone 0, which is a deliberate bound', () => {
expect(getWidgetSoftBounds({ softMin: 0, softMax: null })).toStrictEqual({
softMin: 0,
softMax: undefined,
});
expect(getWidgetSoftBounds({ softMin: null, softMax: 0 })).toStrictEqual({
softMin: undefined,
softMax: 0,
});
});
it('keeps real bounds and normalises null to undefined', () => {
expect(getWidgetSoftBounds({ softMin: 0, softMax: 100 })).toStrictEqual({
softMin: 0,
softMax: 100,
});
expect(getWidgetSoftBounds({ softMin: null, softMax: null })).toStrictEqual({
softMin: undefined,
softMax: undefined,
});
});
});

View File

@@ -1,5 +1,6 @@
import { defaultStyles } from '@visx/tooltip';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Widgets } from 'types/api/widgets/widget';
export const tooltipStyles = {
...defaultStyles,
@@ -104,3 +105,20 @@ export const getTimeRangeFromStepInterval = (
const endTime = xValue + stepInterval;
return { startTime, endTime };
};
/**
* v1 seeded every new widget with softMin: 0, softMax: 0 as its "unset" default, so that
* exact pair carries no user intent and must not reach the chart layer, which honours an
* explicit 0. Any other pair — including a lone 0 — is a deliberate bound.
*/
export const getWidgetSoftBounds = (
widget: Pick<Widgets, 'softMin' | 'softMax'>,
): { softMin?: number; softMax?: number } => {
if (widget.softMin === 0 && widget.softMax === 0) {
return {};
}
return {
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
};
};

View File

@@ -25,10 +25,8 @@ export class UPlotScaleBuilder extends ConfigBuilder<
constructor(props: ScaleProps) {
super(props);
// By default while creating a widget we set the softMin and softMax to 0, so we need to handle this case separately
const isDefaultSoftMinMax = props.softMin === 0 && props.softMax === 0;
this.softMin = isDefaultSoftMinMax ? null : (props.softMin ?? null);
this.softMax = isDefaultSoftMinMax ? null : (props.softMax ?? null);
this.softMin = props.softMin ?? null;
this.softMax = props.softMax ?? null;
this.min = props.min ?? null;
this.max = props.max ?? null;
}

View File

@@ -27,7 +27,7 @@ describe('UPlotScaleBuilder', () => {
jest.clearAllMocks();
});
it('initializes softMin/softMax correctly when both are 0 (treated as unset)', () => {
it('carries an explicit soft bound of 0 through instead of treating it as unset', () => {
const builder = new UPlotScaleBuilder(
createScaleProps({
softMin: 0,
@@ -36,7 +36,16 @@ describe('UPlotScaleBuilder', () => {
);
// Non-time scale so config path uses thresholds pipeline; we just care that
// adjustSoftLimitsWithThresholds receives null soft limits instead of 0/0.
// adjustSoftLimitsWithThresholds receives the bounds as given.
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
builder.getConfig();
expect(adjustSpy).toHaveBeenCalledWith(0, 0, undefined, undefined);
});
it('leaves absent soft bounds null', () => {
const builder = new UPlotScaleBuilder(createScaleProps());
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
builder.getConfig();

View File

@@ -96,6 +96,26 @@ describe('scale utils', () => {
expect(hardMaxOnly).toBe(true);
expect(hasFixedRange).toBe(true);
});
it('treats a soft bound of 0 as set, so the axis can be pinned to 0', () => {
const { rangeConfig } = scaleUtils.getRangeConfig(
null,
null,
0,
null,
0,
0.05,
);
expect(rangeConfig.min).toStrictEqual({
pad: 0,
hard: -Infinity,
soft: 0,
mode: 1,
});
expect(rangeConfig.max.soft).toBeUndefined();
expect(rangeConfig.max.mode).toBe(3);
});
});
describe('createRangeFunction', () => {

View File

@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
sqlmigration.NewClearDashboardAxesSoftBoundsFactory(sqlstore),
)
}

View File

@@ -0,0 +1,133 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type dashboardAxesRow struct {
bun.BaseModel `bun:"table:dashboard"`
ID string `bun:"id,pk"`
Data string `bun:"data"`
}
type clearDashboardAxesSoftBounds struct {
settings factory.ProviderSettings
}
func NewClearDashboardAxesSoftBoundsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("clear_dashboard_soft_bounds"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &clearDashboardAxesSoftBounds{settings: ps}, nil
})
}
func (migration *clearDashboardAxesSoftBounds) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up nulls the axes soft bounds of every v2 panel holding the pair 0/0. v1 seeded that pair
// into each new widget as its "unset" default and migrate_dashboards_v1_to_v2 copied it over
// verbatim, so the chart layer had to ignore it to keep migrated panels auto-scaling — which
// left a deliberate soft min of 0 unsettable. Clearing the sentinel in stored data lets the
// chart honour an explicit 0.
//
// v1 rows have no spec object and so are skipped by the walk rather than by a version check,
// which keeps this migration indifferent to later schema versions.
func (migration *clearDashboardAxesSoftBounds) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*dashboardAxesRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
cleared, changed, ok := clearAxesSoftBounds(row.Data)
if !ok {
migration.settings.Logger.WarnContext(ctx, "dashboard data could not be parsed, leaving it untouched", slog.String("dashboard_id", row.ID))
skipped++
continue
}
if !changed {
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*dashboardAxesRow)(nil)).Set("data = ?", cleared).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "cleared default axes soft bounds on dashboard panels", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *clearDashboardAxesSoftBounds) Down(context.Context, *bun.DB) error {
return nil
}
// clearAxesSoftBounds rewrites stored dashboard data, nulling softMin and softMax on every
// panel whose axes hold 0/0. Panels of any other shape are left as they are, so a malformed
// one still surfaces as a validation error later; ok=false means unparseable.
func clearAxesSoftBounds(data string) (cleared string, changed bool, ok bool) {
var dashboard map[string]any
if err := json.Unmarshal([]byte(data), &dashboard); err != nil {
return "", false, false
}
spec, _ := dashboard["spec"].(map[string]any)
panels, _ := spec["panels"].(map[string]any)
for _, panel := range panels {
axes, found := dashboardPanelAxes(panel)
if !found || !isZeroJSONNumber(axes["softMin"]) || !isZeroJSONNumber(axes["softMax"]) {
continue
}
axes["softMin"] = nil
axes["softMax"] = nil
changed = true
}
if !changed {
return "", false, true
}
clearedJSON, err := marshalUnescaped(dashboard)
if err != nil {
return "", false, false
}
return string(clearedJSON), true, true
}
// dashboardPanelAxes walks spec.plugin.spec.axes on one panel. Only the time series and bar
// chart plugins carry an axes slice; every other kind misses at one of these hops.
func dashboardPanelAxes(panel any) (map[string]any, bool) {
for _, key := range []string{"spec", "plugin", "spec", "axes"} {
object, ok := panel.(map[string]any)
if !ok {
return nil, false
}
panel = object[key]
}
axes, ok := panel.(map[string]any)
return axes, ok
}
func isZeroJSONNumber(value any) bool {
number, ok := value.(float64)
return ok && number == 0
}

View File

@@ -0,0 +1,71 @@
package sqlmigration
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClearAxesSoftBounds(t *testing.T) {
dashboard := func(axes string) string {
return `{"spec":{"panels":{"p1":{"kind":"Panel","spec":{"plugin":{"kind":"signoz/TimeSeries","spec":{"axes":` + axes + `}}}}}}}`
}
axesOf := func(t *testing.T, data string) map[string]any {
t.Helper()
var decoded map[string]any
require.NoError(t, json.Unmarshal([]byte(data), &decoded))
axes, ok := dashboardPanelAxes(decoded["spec"].(map[string]any)["panels"].(map[string]any)["p1"])
require.True(t, ok)
return axes
}
t.Run("clears the 0/0 sentinel", func(t *testing.T) {
cleared, changed, ok := clearAxesSoftBounds(dashboard(`{"softMin":0,"softMax":0,"isLogScale":true}`))
require.True(t, ok)
require.True(t, changed)
axes := axesOf(t, cleared)
assert.Nil(t, axes["softMin"])
assert.Nil(t, axes["softMax"])
assert.Equal(t, true, axes["isLogScale"], "unrelated axes fields survive")
})
t.Run("leaves any other pair alone", func(t *testing.T) {
for name, axes := range map[string]string{
"lone soft min": `{"softMin":0,"softMax":null}`,
"lone soft max": `{"softMin":null,"softMax":0}`,
"real bounds": `{"softMin":0,"softMax":100}`,
"already unset": `{"softMin":null,"softMax":null}`,
"absent": `{"isLogScale":false}`,
"non-numeric": `{"softMin":"0","softMax":"0"}`,
} {
t.Run(name, func(t *testing.T) {
_, changed, ok := clearAxesSoftBounds(dashboard(axes))
require.True(t, ok)
assert.False(t, changed)
})
}
})
t.Run("skips panel kinds and v1 rows that have no axes", func(t *testing.T) {
for name, data := range map[string]string{
"no axes slice": `{"spec":{"panels":{"p1":{"spec":{"plugin":{"spec":{}}}}}}}`,
"v1 dashboard": `{"widgets":[{"softMin":0,"softMax":0}],"layout":[]}`,
"no panels": `{"spec":{"layouts":[]}}`,
} {
t.Run(name, func(t *testing.T) {
_, changed, ok := clearAxesSoftBounds(data)
require.True(t, ok)
assert.False(t, changed)
})
}
})
t.Run("reports unparseable data", func(t *testing.T) {
_, _, ok := clearAxesSoftBounds(`not json`)
assert.False(t, ok)
})
}

View File

@@ -254,14 +254,26 @@ func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
// axesFromWidget drops the exact softMin/softMax pair 0/0: v1 seeded it into every new
// widget as its "unset" default, so it carries no user intent, while in v2 an explicit 0
// is an honoured bound. Any other pair — including a lone 0 — is carried over as-is.
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
softMin, softMax := d.readFloatPtr(w, "softMin"), d.readFloatPtr(w, "softMax")
if isV1DefaultSoftBounds(softMin, softMax) {
softMin, softMax = nil, nil
}
return Axes{
SoftMin: d.readFloatPtr(w, "softMin"),
SoftMax: d.readFloatPtr(w, "softMax"),
SoftMin: softMin,
SoftMax: softMax,
IsLogScale: d.readBool(w, "isLogScale"),
}
}
func isV1DefaultSoftBounds(softMin, softMax *float64) bool {
return softMin != nil && softMax != nil && *softMin == 0 && *softMax == 0
}
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),

View File

@@ -333,6 +333,37 @@ func TestConvertGraphWidgetToTimeSeriesPanel(t *testing.T) {
assert.Equal(t, "high", threshold.Label)
}
func TestConvertGraphWidgetDropsV1DefaultSoftBounds(t *testing.T) {
widget := func(bounds map[string]any) map[string]any {
w := map[string]any{"id": "widget-1", "panelTypes": "graph", "title": "Request rate"}
for k, v := range bounds {
w[k] = v
}
return w
}
t.Run("0/0 is v1's unset default and is dropped", func(t *testing.T) {
panel := (&v1Decoder{}).convertGraphWidget(widget(map[string]any{"softMin": float64(0), "softMax": float64(0)}))
require.NotNil(t, panel)
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.True(t, ok)
assert.Nil(t, spec.Axes.SoftMin)
assert.Nil(t, spec.Axes.SoftMax)
})
t.Run("a lone 0 is a deliberate bound and is kept", func(t *testing.T) {
panel := (&v1Decoder{}).convertGraphWidget(widget(map[string]any{"softMin": float64(0)}))
require.NotNil(t, panel)
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.True(t, ok)
require.NotNil(t, spec.Axes.SoftMin)
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
assert.Nil(t, spec.Axes.SoftMax)
})
}
func TestConvertGraphWidgetDefaultsForMissingFields(t *testing.T) {
widget := map[string]any{
"id": "widget-1",