Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
e728942fec fix: handle malformed reduceTo + pick correct columns for traces in list panel (#12275)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-07-25 14:57:29 +00:00
Naman Verma
5bf95534ad fix: reencode svg images to base64 when migrating dashboards (#12274)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-25 13:01:34 +00:00
8 changed files with 432 additions and 33 deletions

View File

@@ -2,6 +2,7 @@ package impldashboard
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
@@ -12,8 +13,10 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -30,21 +33,190 @@ func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettin
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError(
"create a dashboard with POST /api/v2/dashboards (schema: "+dashboardtypes.V2CreateDashboardSchemaLink+")"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PostableDashboard{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
render.Error(rw, err)
return
}
dashboardMigrator := transition.NewDashboardMigrateV5(handler.providerSettings.Logger, nil, nil)
if req["version"] != "v5" {
dashboardMigrator.Migrate(ctx, req)
}
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), dashboardtypes.SourceUser, req)
if err != nil {
render.Error(rw, err)
return
}
gettableDashboard, err := dashboardtypes.NewGettableDashboardFromDashboard(dashboard)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableDashboard)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError(
"update a dashboard with PUT /api/v2/dashboards/{id} (schema: "+dashboardtypes.V2UpdateDashboardSchemaLink+"), or patch it with PATCH /api/v2/dashboards/{id} (schema: "+dashboardtypes.V2PatchDashboardSchemaLink+")"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatableDashboard{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
render.Error(rw, err)
return
}
diff := 0
// Allow multiple deletions for API key requests; enforce for others
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer {
diff = 1
}
dashboard, err := handler.module.Update(ctx, orgID, dashboardID, claims.Email, req, diff)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard)
}
func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError("lock a dashboard with PUT /api/v2/dashboards/{id}/lock, or unlock it with DELETE /api/v2/dashboards/{id}/lock"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := new(dashboardtypes.LockUnlockDashboard)
err = json.NewDecoder(r.Body).Decode(req)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, isAdmin, *req.Locked)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError("delete a dashboard with DELETE /api/v2/dashboards/{id}"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.Delete(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) CreatePublic(rw http.ResponseWriter, r *http.Request) {

View File

@@ -1078,11 +1078,75 @@ func prepareQuery(r *http.Request) (string, error) {
}
func (aH *APIHandler) Get(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError("get a dashboard with GET /api/v2/dashboards/{id}"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := aH.Signoz.Modules.Dashboard.Get(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
gettableDashboard, err := dashboardtypes.NewGettableDashboardFromDashboard(dashboard)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, gettableDashboard)
}
func (aH *APIHandler) List(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, dashboardtypes.NewV1DeprecatedError("list dashboards with GET /api/v2/dashboards"))
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
dashboards, err := aH.Signoz.Modules.Dashboard.List(ctx, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
render.Error(rw, err)
return
}
gettableDashboards, err := dashboardtypes.NewGettableDashboardsFromDashboards(dashboards)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, gettableDashboards)
}
func (aH *APIHandler) queryDashboardVarsV2(w http.ResponseWriter, r *http.Request) {

View File

@@ -22,7 +22,6 @@ var (
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
ErrCodeDashboardV1Deprecated = errors.MustNewCode("dashboard_deprecated")
)
type StorableDashboard struct {

View File

@@ -1,5 +1,11 @@
package dashboardtypes
import (
"encoding/base64"
"net/url"
"strings"
)
// v1 dashboards stored their `image` as one of a fixed set of base64 icon data
// URIs. v2 references the same icons by a stable asset path (dashboardIconPathPrefix
// + name). This maps each preset v1 base64 blob to its v2 icon path so a migrated
@@ -45,8 +51,37 @@ func resolveV1Image(image string) string {
if path, ok := v1Base64IconToAssetPath[image]; ok {
return path
}
if reencoded, ok := reencodeInlineSVGImage(image); ok {
image = reencoded
}
if (DashboardV2MetadataBase{Image: image}).validateImage() == nil {
return image
}
return defaultDashboardIconPath
}
// reencodeInlineSVGImage rewrites a percent-encoded inline SVG data URI into the
// base64 form the v2 schema accepts, preserving an icon that would otherwise fail
// validation. Returns (image, false) unchanged for anything else.
func reencodeInlineSVGImage(image string) (string, bool) {
const mediaType = "data:image/svg+xml"
if !strings.HasPrefix(image, mediaType) {
return image, false
}
rest := image[len(mediaType):]
// The media type must be followed by its params/data separator, not more type
// text (guards against a longer type that merely shares this prefix).
if len(rest) == 0 || (rest[0] != ',' && rest[0] != ';') {
return image, false
}
params, encoded, found := strings.Cut(rest, ",")
// A base64 URI carries ";base64" before the comma and is already valid — leave it.
if !found || strings.Contains(params, "base64") {
return image, false
}
svg, err := url.PathUnescape(encoded)
if err != nil {
return image, false // malformed escaping — leave it; conversion falls back to the default icon
}
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)), true
}

View File

@@ -266,11 +266,27 @@ func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
}
}
// widgetBuilderSignal returns the signal of the widget's first builder query. Empty
// for non-builder (promql/clickhouse) widgets, which have no per-signal selected fields.
func (d *v1Decoder) widgetBuilderSignal(w map[string]any) telemetrytypes.Signal {
builder := d.readObject(d.readObject(w, "query"), "builder")
for _, q := range d.readObjects(builder, "queryData") {
return signalFromDataSource(q["dataSource"])
}
return telemetrytypes.Signal{}
}
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
field := "selectedLogFields"
// Read the array matching the query's signal: traces and logs each keep their own
// selected columns, and a widget can carry a stale array for the other signal.
primary, fallback := "selectedLogFields", "selectedTracesFields"
if d.widgetBuilderSignal(w) == telemetrytypes.SignalTraces {
primary, fallback = "selectedTracesFields", "selectedLogFields"
}
field := primary
raw := d.readArray(w, field)
if len(raw) == 0 {
field = "selectedTracesFields"
field = fallback
raw = d.readArray(w, field)
}
if len(raw) == 0 {

View File

@@ -33,6 +33,7 @@ var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandle
func normalizePreV5QueryData(query map[string]any, widgetType string) {
dropLegacyFilter(query)
normalizeFilterItemOps(query)
foldReduceToIntoMetricAggregations(query)
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
normalizeMetricAggregations(query)
@@ -226,7 +227,7 @@ func dropLegacyFilter(query map[string]any) {
// normalizeFilterItemOps lowercases exists/nexists filter ops (frontend stores them
// uppercase) to the spelling transition's buildCondition (pkg/transition/migrate_common.go)
// matches; otherwise it appends a spurious empty value ("svc EXISTS ''"). Value
// matches; otherwise it appends a spurious empty value ("svc EXISTS "). Value
// operators already round-trip via that switch's default case.
func normalizeFilterItemOps(query map[string]any) {
filters, ok := query["filters"].(map[string]any)
@@ -303,6 +304,32 @@ func normalizeMetricAggregations(query map[string]any) {
}
}
// foldReduceToIntoMetricAggregations moves a metric query's top-level reduceTo onto
// its existing aggregations[] (where v5 wants it), which the shared migrator only does
// when building an aggregation from flat fields. Runs before it so the value survives.
func foldReduceToIntoMetricAggregations(query map[string]any) {
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
return
}
reduceTo, ok := query["reduceTo"].(string)
if !ok || reduceTo == "" {
return
}
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return
}
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
if _, exists := agg["reduceTo"]; !exists {
agg["reduceTo"] = reduceTo
}
}
}
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
// via parseAggregations (extract func(args), lift inline "as alias", split
// multi-part, drop metric-only fields; empty → count()). Covers the case the

View File

@@ -429,6 +429,11 @@ func TestResolveV1Image(t *testing.T) {
assert.Equal(t, passthrough, resolveV1Image(passthrough))
}
// A percent-encoded inline SVG icon is preserved by re-encoding it to base64
// (the form v2 accepts) rather than being dropped to the default.
svgResolved := resolveV1Image("data:image/svg+xml,%3Csvg%2F%3E")
assert.Equal(t, "data:image/svg+xml;base64,PHN2Zy8+", svgResolved)
// A value that v2 would reject (a URL, a corrupt data URI) falls back to the
// default eight-ball icon rather than failing the dashboard migration.
for _, invalid := range []string{
@@ -440,6 +445,66 @@ func TestResolveV1Image(t *testing.T) {
}
}
func TestReencodeInlineSVGImage(t *testing.T) {
testCases := []struct {
scenario string
image string
wantRewritten bool
want string
}{
{
scenario: "percent-encoded inline svg",
image: "data:image/svg+xml,%3Csvg%2F%3E",
wantRewritten: true,
want: "data:image/svg+xml;base64,PHN2Zy8+",
},
{
scenario: "percent-encoded inline svg with charset param",
image: "data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E",
wantRewritten: true,
want: "data:image/svg+xml;base64,PHN2Zy8+",
},
{
scenario: "already base64 svg is left unchanged",
image: "data:image/svg+xml;base64,PHN2Zy8+",
wantRewritten: false,
want: "data:image/svg+xml;base64,PHN2Zy8+",
},
{
scenario: "non-svg data uri is left unchanged",
image: "data:image/png,%89PNG",
wantRewritten: false,
want: "data:image/png,%89PNG",
},
{
scenario: "an icon path is left unchanged",
image: "/assets/Icons/eight-ball",
wantRewritten: false,
want: "/assets/Icons/eight-ball",
},
{
scenario: "a longer media type merely sharing the svg prefix is left unchanged",
image: "data:image/svg+xmlish,%3Csvg%2F%3E",
wantRewritten: false,
want: "data:image/svg+xmlish,%3Csvg%2F%3E",
},
{
scenario: "empty is left unchanged",
image: "",
wantRewritten: false,
want: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
got, rewritten := reencodeInlineSVGImage(testCase.image)
assert.Equal(t, testCase.wantRewritten, rewritten)
assert.Equal(t, testCase.want, got)
})
}
}
func TestConvertV1ToV2ReplacesInvalidImage(t *testing.T) {
// An image v2 would reject must not sink the whole migration — it falls back
// to the default icon so the dashboard still converts.
@@ -459,6 +524,48 @@ func TestConvertV1ToV2ReplacesInvalidImage(t *testing.T) {
assert.Equal(t, "/assets/Icons/eight-ball", dashboard.Image)
}
func TestFoldReduceToIntoMetricAggregations(t *testing.T) {
// A metric query's top-level reduceTo lands on its aggregation (where v5 wants it).
query := map[string]any{
"dataSource": "metrics",
"reduceTo": "sum",
"aggregations": []any{map[string]any{"metricName": "m", "spaceAggregation": "sum"}},
}
foldReduceToIntoMetricAggregations(query)
assert.Equal(t, "sum", query["aggregations"].([]any)[0].(map[string]any)["reduceTo"])
// An aggregation that already carries a reduceTo is not overwritten.
query = map[string]any{
"dataSource": "metrics",
"reduceTo": "sum",
"aggregations": []any{map[string]any{"metricName": "m", "reduceTo": "avg"}},
}
foldReduceToIntoMetricAggregations(query)
assert.Equal(t, "avg", query["aggregations"].([]any)[0].(map[string]any)["reduceTo"])
}
func TestMapV1SelectFieldsPicksBySignal(t *testing.T) {
d := &v1Decoder{}
// A list widget carrying both arrays; the query's signal must decide which wins.
widget := func(dataSource string) map[string]any {
return map[string]any{
"query": map[string]any{
"builder": map[string]any{"queryData": []any{map[string]any{"dataSource": dataSource}}},
},
"selectedLogFields": []any{map[string]any{"name": "log_field"}},
"selectedTracesFields": []any{map[string]any{"name": "trace_field"}},
}
}
logs := d.mapV1SelectFields(widget("logs"))
require.Len(t, logs, 1)
assert.Equal(t, "log_field", logs[0].Name)
traces := d.mapV1SelectFields(widget("traces"))
require.Len(t, traces, 1)
assert.Equal(t, "trace_field", traces[0].Name)
}
func TestConvertV1ToV2RejectsAlreadyV2(t *testing.T) {
storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},

View File

@@ -1,21 +0,0 @@
package dashboardtypes
import "github.com/SigNoz/signoz/pkg/errors"
// The v1 dashboard API is superseded by the v2 dashboard API; every v1 endpoint
// now returns a deprecation error pointing at its v2 replacement. The body-carrying
// endpoints (create, update) point to the v2 request-schema docs below; the
// body-less endpoints just name the v2 endpoint to call.
const (
V2CreateDashboardSchemaLink = "https://signoz.io/dashboards/schema/dashboard_create.json"
V2UpdateDashboardSchemaLink = "https://signoz.io/dashboards/schema/dashboard_update.json"
V2PatchDashboardSchemaLink = "https://signoz.io/dashboards/schema/dashboard_patch.json"
)
// NewV1DeprecatedError builds the error a deprecated v1 dashboard endpoint returns.
// useInstead names the v2 replacement — a schema link for the create/update
// endpoints, or the v2 endpoint to call for the body-less ones.
func NewV1DeprecatedError(useInstead string) error {
return errors.Newf(errors.TypeUnsupported, ErrCodeDashboardV1Deprecated,
"the v1 dashboard API is deprecated; instead, %s", useInstead)
}