Compare commits

...

2 Commits

Author SHA1 Message Date
Naman Verma
181878f1df chore: generate api specs 2026-07-08 02:22:15 +05:30
Naman Verma
db54237f77 fix: don't return error in v2 list dashboard api if there is a v1 dashboard 2026-07-08 02:14:55 +05:30
6 changed files with 208 additions and 16 deletions

View File

@@ -3148,6 +3148,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3180,6 +3182,7 @@ components:
- name
- tags
- spec
- legacy
- pinned
type: object
DashboardtypesListedDashboardV2:
@@ -3193,6 +3196,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3223,6 +3228,7 @@ components:
- name
- tags
- spec
- legacy
type: object
DashboardtypesListedDashboardV2Spec:
properties:

View File

@@ -5005,6 +5005,10 @@ export interface DashboardtypesListedDashboardForUserV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/
@@ -5080,6 +5084,10 @@ export interface DashboardtypesListedDashboardV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/

View File

@@ -71,7 +71,7 @@ func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *das
return nil, err
}
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags), nil
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
@@ -90,7 +90,7 @@ func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, user
return nil, err
}
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags), nil
}
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {

View File

@@ -64,6 +64,13 @@ type (
ListableDashboard []*GettableDashboard
)
// readString reads a string field from the untyped data blob, yielding "" when
// the key is absent, null, or not a string.
func (d StorableDashboardData) readString(key string) string {
s, _ := d[key].(string)
return s
}
func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard, error) {
dashboardID, err := valuer.NewUUID(dashboard.ID)
if err != nil {

View File

@@ -122,6 +122,10 @@ type listedDashboardV2 struct {
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
// schema. Such rows are extracted best-effort from the v1 shape so the list
// still surfaces them; callers should route them to the legacy view.
Legacy bool `json:"legacy" required:"true"`
}
type listedDashboardV2Spec struct {
@@ -144,6 +148,41 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
}
}
// newListedDashboardForList builds the list view for a single dashboard. A row
// whose stored data is not in the v2 (perses) schema is extracted best-effort
// from the v1 shape and flagged Legacy, so one legacy or malformed dashboard
// can't fail the whole list.
func newListedDashboardForList(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
if v2, err := storable.ToDashboardV2(tags); err == nil {
return newListedDashboardV2(v2)
}
return newLegacyListedDashboardV2(storable, tags)
}
// newLegacyListedDashboardV2 pulls the display-relevant fields out of a v1
// dashboard blob. Column-backed fields (name, source, timestamps, …) come
// straight off the row; title/description/image/version are read leniently from
// the untyped v1 data, defaulting to zero when absent or of the wrong type.
func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
return &listedDashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
SchemaVersion: storable.Data.readString("version"),
Name: storable.Name,
Image: storable.Data.readString("image"),
Tags: tagtypes.NewGettableTagsFromTags(tags),
Spec: listedDashboardV2Spec{Display: Display{
Name: storable.Data.readString("title"),
Description: storable.Data.readString("description"),
}},
Legacy: true,
}
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
@@ -151,21 +190,17 @@ type ListableDashboardV2 struct {
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
items := make([]*listedDashboardV2, len(dashboards))
for i, d := range dashboards {
v2, err := d.ToDashboardV2(tagsByEntity[d.ID])
if err != nil {
return nil, err
}
items[i] = newListedDashboardV2(v2)
items[i] = newListedDashboardForList(d, tagsByEntity[d.ID])
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}
// listedDashboardForUserV2 is a listed dashboard plus the calling user's pin
@@ -190,15 +225,11 @@ type StorableDashboardWithPinInfo struct {
Pinned bool
}
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardForUserV2, error) {
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardForUserV2 {
items := make([]*listedDashboardForUserV2, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
items[i] = &listedDashboardForUserV2{
listedDashboardV2: *newListedDashboardV2(v2),
listedDashboardV2: *newListedDashboardForList(r.Dashboard, tagsByEntity[r.Dashboard.ID]),
Pinned: r.Pinned,
}
}
@@ -207,5 +238,5 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}

View File

@@ -305,6 +305,146 @@ func TestNextCloneDisplayName(t *testing.T) {
}
}
func TestNewLegacyListedDashboardV2(t *testing.T) {
orgID := valuer.GenerateUUID()
dashboardID := valuer.GenerateUUID()
createdAt := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
t.Run("extracts display fields from a well-formed v1 blob", func(t *testing.T) {
storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: dashboardID},
TimeAuditable: types.TimeAuditable{CreatedAt: createdAt, UpdatedAt: updatedAt},
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
OrgID: orgID,
Locked: true,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{
"title": "Legacy Title",
"description": "an old v1 dashboard",
"image": "data:image/png;base64,xyz",
"version": "v5",
"widgets": []any{},
},
}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy, "a non-v2 dashboard must be flagged legacy")
assert.Equal(t, storable.Identifiable, listed.Identifiable)
assert.Equal(t, storable.TimeAuditable, listed.TimeAuditable)
assert.Equal(t, storable.UserAuditable, listed.UserAuditable)
assert.Equal(t, orgID, listed.OrgID)
assert.True(t, listed.Locked)
assert.Equal(t, SourceUser, listed.Source)
assert.Equal(t, "legacy-dashboard", listed.Name, "name comes off the column, not the blob")
assert.Equal(t, "v5", listed.SchemaVersion)
assert.Equal(t, "data:image/png;base64,xyz", listed.Image)
assert.Equal(t, "Legacy Title", listed.Spec.Display.Name)
assert.Equal(t, "an old v1 dashboard", listed.Spec.Display.Description)
assert.Empty(t, listed.Tags, "v1 dashboards predate tags; nil converts to an empty, non-nil slice")
})
t.Run("yields zero values for absent or wrongly-typed fields", func(t *testing.T) {
storable := &StorableDashboard{
OrgID: orgID,
Source: SourceUser,
Data: StorableDashboardData{
"title": 42, // wrong type
"version": []any{"v5"}, // wrong type
"image": nil, // null
},
}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy)
assert.Empty(t, listed.Spec.Display.Name)
assert.Empty(t, listed.SchemaVersion)
assert.Empty(t, listed.Image)
assert.Empty(t, listed.Tags, "nil tags convert to an empty, non-nil slice")
})
t.Run("tolerates entirely empty data", func(t *testing.T) {
storable := &StorableDashboard{OrgID: orgID, Source: SourceUser, Name: "bare"}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy)
assert.Equal(t, "bare", listed.Name)
assert.Empty(t, listed.Spec.Display.Name)
})
}
func TestNewListableDashboardV2MixedSchemas(t *testing.T) {
orgID := valuer.GenerateUUID()
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
v2Storable, err := v2Dashboard.ToStorableDashboard()
require.NoError(t, err)
v1Storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: orgID,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{
"title": "Legacy Title",
"version": "v5",
"widgets": []any{},
},
}
tagsByEntity := map[valuer.UUID][]*tagtypes.Tag{
v2Storable.ID: v2Dashboard.Tags,
}
listable := NewListableDashboardV2([]*StorableDashboard{v2Storable, v1Storable}, 2, tagsByEntity, nil)
require.Len(t, listable.Dashboards, 2, "a single legacy dashboard must not drop rows from the list")
assert.Equal(t, int64(2), listable.Total)
v2Row := listable.Dashboards[0]
assert.False(t, v2Row.Legacy, "a v2 dashboard is not legacy")
assert.Equal(t, SchemaVersion, v2Row.SchemaVersion)
assert.Equal(t, "Test Dashboard", v2Row.Spec.Display.Name)
v1Row := listable.Dashboards[1]
assert.True(t, v1Row.Legacy, "a v1 dashboard is flagged legacy")
assert.Equal(t, "v5", v1Row.SchemaVersion)
assert.Equal(t, "Legacy Title", v1Row.Spec.Display.Name)
assert.Equal(t, "legacy-dashboard", v1Row.Name)
}
func TestNewListableDashboardForUserV2MixedSchemas(t *testing.T) {
orgID := valuer.GenerateUUID()
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
v2Storable, err := v2Dashboard.ToStorableDashboard()
require.NoError(t, err)
v1Storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: orgID,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{"title": "Legacy Title", "version": "v5"},
}
rows := []*StorableDashboardWithPinInfo{
{Dashboard: v2Storable, Pinned: true},
{Dashboard: v1Storable, Pinned: false},
}
listable := NewListableDashboardForUserV2(rows, 2, nil, nil)
require.Len(t, listable.Dashboards, 2)
assert.False(t, listable.Dashboards[0].Legacy)
assert.True(t, listable.Dashboards[0].Pinned)
assert.True(t, listable.Dashboards[1].Legacy)
assert.False(t, listable.Dashboards[1].Pinned)
}
func TestDashboardV2StorableRoundTrip(t *testing.T) {
orgID := valuer.GenerateUUID()
original := newTestDashboardV2(t, orgID, SourceIntegration)