mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-21 10:50:41 +01:00
## Summary
- Handle malformed selectedFields in the extradata in the migration and
new migration to fix in the already migrated cases.
- Restructure saved-view create/update/get payloads so
`schemaVersion`/`spec` are top-level (unwrapping the old `data`
nesting), matching how dashboards and rules shape their wire types.
- Publish `schemaVersion` as an `enum: [v2]`
- Make `display` and `selectedFields` optional in the OpenAPI schema
- Declare `409` on `CreateSavedView`
- Require `minItems: 1` on `queries`
New API contract in [below
comment](https://github.com/SigNoz/signoz/pull/12477#issuecomment-5230041074),
follow up on https://github.com/SigNoz/signoz/pull/12342
Closes https://github.com/SigNoz/engineering-pod/issues/4651
Notes to reviewer:
- Please pay attention to the last case in above linked comment for
partial display field updates.
- Still assuming that [migration
046](6372af75a6/pkg/sqlmigration/046_update_dashboard_alert_and_saved_view_v5.go (L233))
has already migrated all the views to v5 QB format and don't need to do
that now.
- Breaking change: queries are not validated in the v1 APIs as well, so
any incorrect query will be rejected
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
264 lines
10 KiB
Go
264 lines
10 KiB
Go
package implsavedview
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
|
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
|
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
|
"github.com/SigNoz/signoz/pkg/valuer"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func testQueries() []qbtypes.QueryEnvelope {
|
|
return []qbtypes.QueryEnvelope{
|
|
{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
|
Signal: telemetrytypes.SignalLogs,
|
|
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
|
|
t.Run("all fields carried over", func(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "my view",
|
|
SourcePage: "logs",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeGraph,
|
|
Queries: testQueries(),
|
|
},
|
|
ExtraData: `{"color":"blue","selectColumns":[{"name":"service.name"}],"format":"table","maxLines":10,"fontSize":"large"}`,
|
|
}
|
|
|
|
postable := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Empty(t, postable.Name, "v1 has no slug concept -- name must always be generated")
|
|
assert.True(t, postable.GenerateName, "v1 has no slug concept -- name must always be generated")
|
|
assert.Equal(t, "my view", postable.Spec.DisplayName)
|
|
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
|
|
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.SchemaVersion)
|
|
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Spec.PanelType)
|
|
assert.Equal(t, qbtypes.RequestTypeTimeSeries, postable.Spec.RequestType, "graph panel type must map to the time_series request type")
|
|
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Spec.Queries)
|
|
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Spec.SelectedFields)
|
|
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Spec.Display)
|
|
})
|
|
|
|
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "no extra data",
|
|
SourcePage: "traces",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeTable,
|
|
Queries: testQueries(),
|
|
},
|
|
ExtraData: "",
|
|
}
|
|
|
|
postable := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Equal(t, savedviewtypes.PanelTypeTable, postable.Spec.PanelType)
|
|
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
|
|
assert.Nil(t, postable.Spec.SelectedFields)
|
|
})
|
|
|
|
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "malformed extra data",
|
|
SourcePage: "metrics",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeList,
|
|
Queries: testQueries(),
|
|
},
|
|
ExtraData: `{not valid json`,
|
|
}
|
|
|
|
postable := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Equal(t, "malformed extra data", postable.Spec.DisplayName)
|
|
assert.Equal(t, savedviewtypes.PanelTypeList, postable.Spec.PanelType)
|
|
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
|
|
})
|
|
|
|
t.Run("legacy validation gap: empty builderQueries map with no queries", func(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "no real queries",
|
|
SourcePage: "logs",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeGraph,
|
|
QueryType: v3.QueryTypeBuilder,
|
|
BuilderQueries: map[string]*v3.BuilderQuery{},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, legacy.Validate(), "the legacy CompositeQuery check is expected to miss this")
|
|
|
|
postable := newPostableSavedViewFromLegacyView(legacy)
|
|
assert.Error(t, postable.Validate(), "the converted postable must catch what the legacy check missed")
|
|
})
|
|
|
|
t.Run("list panel query with no aggregation is valid", func(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "raw list view",
|
|
SourcePage: "traces",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeList,
|
|
Queries: []qbtypes.QueryEnvelope{{
|
|
Type: qbtypes.QueryTypeBuilder,
|
|
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
|
Signal: telemetrytypes.SignalTraces,
|
|
Filter: &qbtypes.Filter{Expression: "db_name = 'two'"},
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
postable := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Equal(t, qbtypes.RequestTypeRaw, postable.Spec.RequestType, "list panel type must map to the raw request type")
|
|
assert.NoError(t, postable.Validate(), "a raw list query must not be required to carry an aggregation")
|
|
})
|
|
}
|
|
|
|
func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
|
|
legacy := &v3.SavedView{
|
|
Name: "renamed view",
|
|
SourcePage: "traces",
|
|
CompositeQuery: &v3.CompositeQuery{
|
|
PanelType: v3.PanelTypeTable,
|
|
Queries: testQueries(),
|
|
},
|
|
ExtraData: `{"color":"red"}`,
|
|
}
|
|
|
|
updatable := newUpdatableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
|
|
assert.Equal(t, savedviewtypes.SourceTraces, updatable.Source)
|
|
assert.Equal(t, qbtypes.RequestTypeScalar, updatable.Spec.RequestType, "table panel type must map to the scalar request type")
|
|
}
|
|
|
|
func TestNewLegacyViewFromSavedView(t *testing.T) {
|
|
now := time.Now()
|
|
savedView := &savedviewtypes.SavedView{
|
|
Name: "my-view-abc123ef",
|
|
Source: savedviewtypes.SourceLogs,
|
|
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
|
Spec: savedviewtypes.SavedViewSpec{
|
|
DisplayName: "my view",
|
|
PanelType: savedviewtypes.PanelTypeGraph,
|
|
Queries: testQueries(),
|
|
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
|
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
|
|
},
|
|
}
|
|
savedView.ID = valuer.GenerateUUID()
|
|
savedView.CreatedAt = now
|
|
savedView.UpdatedAt = now
|
|
savedView.CreatedBy = "creator@signoz.io"
|
|
savedView.UpdatedBy = "updater@signoz.io"
|
|
|
|
legacy, err := newLegacyViewFromSavedView(savedView)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, savedView.ID, legacy.ID)
|
|
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
|
|
assert.Equal(t, savedView.CreatedAt, legacy.CreatedAt)
|
|
assert.Equal(t, savedView.CreatedBy, legacy.CreatedBy)
|
|
assert.Equal(t, savedView.UpdatedAt, legacy.UpdatedAt)
|
|
assert.Equal(t, savedView.UpdatedBy, legacy.UpdatedBy)
|
|
assert.Equal(t, "logs", legacy.SourcePage)
|
|
assert.Equal(t, v3.PanelTypeGraph, legacy.CompositeQuery.PanelType)
|
|
assert.Equal(t, v3.QueryTypeBuilder, legacy.CompositeQuery.QueryType)
|
|
assert.Equal(t, savedView.Spec.Queries, legacy.CompositeQuery.Queries)
|
|
|
|
var extra legacyExtraData
|
|
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
|
|
assert.Equal(t, "blue", extra.Color)
|
|
assert.Equal(t, savedView.Spec.SelectedFields, extra.SelectColumns)
|
|
assert.Equal(t, "table", extra.Format)
|
|
assert.Equal(t, 10, extra.MaxLines)
|
|
assert.Equal(t, "large", extra.FontSize)
|
|
}
|
|
|
|
func TestNewLegacyViewsFromSavedViews(t *testing.T) {
|
|
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}
|
|
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}
|
|
|
|
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
|
|
require.NoError(t, err)
|
|
require.Len(t, legacyViews, 2)
|
|
assert.Equal(t, "a", legacyViews[0].Name)
|
|
assert.Equal(t, "b", legacyViews[1].Name)
|
|
}
|
|
|
|
// TestLegacyViewRoundTrip guards the whole v1<->v2 bridge: converting a
|
|
// SavedView to its legacy shape and back must recover the fields the legacy
|
|
// frontend round-trips through (displayName, source, panelType, queries,
|
|
// selectedFields, display) -- these two functions are each other's inverse
|
|
// on the API surface, so a regression in either should fail this. The internal
|
|
// slug (Name) is deliberately NOT part of this contract -- v1 never sees it.
|
|
func TestLegacyViewRoundTrip(t *testing.T) {
|
|
original := &savedviewtypes.SavedView{
|
|
Name: "round-trip-abc123ef",
|
|
Source: savedviewtypes.SourceMetrics,
|
|
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
|
Spec: savedviewtypes.SavedViewSpec{
|
|
DisplayName: "round trip",
|
|
PanelType: savedviewtypes.PanelTypeTable,
|
|
Queries: testQueries(),
|
|
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
|
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
|
|
},
|
|
}
|
|
|
|
legacy, err := newLegacyViewFromSavedView(original)
|
|
require.NoError(t, err)
|
|
|
|
roundTripped := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Empty(t, roundTripped.Name)
|
|
assert.True(t, roundTripped.GenerateName)
|
|
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
|
|
assert.Equal(t, original.Source, roundTripped.Source)
|
|
assert.Equal(t, original.Spec.Queries, roundTripped.Spec.Queries)
|
|
assert.Equal(t, original.Spec.SelectedFields, roundTripped.Spec.SelectedFields)
|
|
assert.Equal(t, original.Spec.PanelType, roundTripped.Spec.PanelType)
|
|
assert.Equal(t, original.Spec.Display, roundTripped.Spec.Display)
|
|
}
|
|
|
|
func TestLegacyViewRoundTrip_EmptySelectedFieldsAndDisplay(t *testing.T) {
|
|
original := &savedviewtypes.SavedView{
|
|
Name: "round-trip-empty-abc123ef",
|
|
Source: savedviewtypes.SourceMetrics,
|
|
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
|
Spec: savedviewtypes.SavedViewSpec{
|
|
DisplayName: "round trip empty",
|
|
PanelType: savedviewtypes.PanelTypeTable,
|
|
Queries: testQueries(),
|
|
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
|
},
|
|
}
|
|
|
|
legacy, err := newLegacyViewFromSavedView(original)
|
|
require.NoError(t, err)
|
|
|
|
var extra legacyExtraData
|
|
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
|
|
assert.Nil(t, extra.SelectColumns, "omitempty drops an empty selectColumns from extraData entirely")
|
|
|
|
roundTripped := newPostableSavedViewFromLegacyView(legacy)
|
|
|
|
assert.Empty(t, roundTripped.Spec.SelectedFields, "empty, not necessarily non-nil, on this leg of the round trip")
|
|
assert.Equal(t, savedviewtypes.PanelTypeTable, roundTripped.Spec.PanelType)
|
|
assert.Equal(t, savedviewtypes.Display{}, roundTripped.Spec.Display)
|
|
}
|