mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-15 16:00:41 +01:00
Compare commits
4 Commits
feat/sqlco
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228dc97b44 | ||
|
|
939a39d13c | ||
|
|
bca4a5e5dc | ||
|
|
727e4ffc18 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewClearDashboardAxesSoftBoundsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
133
pkg/sqlmigration/127_clear_dashboard_axes_soft_bounds.go
Normal file
133
pkg/sqlmigration/127_clear_dashboard_axes_soft_bounds.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user