From 22b88988a4c8e0e2e63e6143bc0627b33ff018a0 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 6 Oct 2025 21:08:10 +0300 Subject: [PATCH] Stars: include query history (#111979) --- apps/preferences/go.mod | 2 +- .../pkg/apis/preferences/v1alpha1/stars.go | 80 ++++++ .../apis/preferences/v1alpha1/stars_test.go | 235 ++++++++++++++++++ pkg/registry/apis/preferences/admission.go | 35 +++ .../apis/preferences/legacy/queries.go | 32 ++- .../apis/preferences/legacy/queries_test.go | 43 +++- pkg/registry/apis/preferences/legacy/sql.go | 94 ++++++- ...tars_query.sql => sql_dashboard_stars.sql} | 0 ...tars_rv.sql => sql_dashboard_stars_rv.sql} | 0 .../preferences/legacy/sql_history_stars.sql | 9 + .../legacy/sql_history_stars_delete.sql | 6 + .../legacy/sql_history_stars_insert.sql | 4 + pkg/registry/apis/preferences/legacy/stars.go | 69 ++++- ...sql => mysql--sql_dashboard_stars-all.sql} | 0 ...sql => mysql--sql_dashboard_stars-org.sql} | 0 ...ql => mysql--sql_dashboard_stars-user.sql} | 0 ... => mysql--sql_dashboard_stars_rv-get.sql} | 0 .../testdata/mysql--sql_history_stars-org.sql | 6 + ...l_history_stars_delete-remove all star.sql | 3 + ...sql--sql_history_stars_insert-add star.sql | 4 + ... => postgres--sql_dashboard_stars-all.sql} | 0 ... => postgres--sql_dashboard_stars-org.sql} | 0 ...=> postgres--sql_dashboard_stars-user.sql} | 0 ... postgres--sql_dashboard_stars_rv-get.sql} | 0 .../postgres--sql_history_stars-org.sql | 6 + ...l_history_stars_delete-remove all star.sql | 3 + ...res--sql_history_stars_insert-add star.sql | 4 + ...ql => sqlite--sql_dashboard_stars-all.sql} | 0 ...ql => sqlite--sql_dashboard_stars-org.sql} | 0 ...l => sqlite--sql_dashboard_stars-user.sql} | 0 ...=> sqlite--sql_dashboard_stars_rv-get.sql} | 0 .../sqlite--sql_history_stars-org.sql | 6 + ...l_history_stars_delete-remove all star.sql | 3 + ...ite--sql_history_stars_insert-add star.sql | 4 + pkg/registry/apis/preferences/register.go | 7 +- pkg/registry/apis/preferences/stars.go | 62 +---- pkg/registry/apis/preferences/stars_update.go | 60 +---- .../apis/preferences/stars_update_test.go | 185 -------------- pkg/server/wire_gen.go | 4 +- pkg/services/queryhistory/api.go | 20 ++ pkg/services/queryhistory/client.go | 103 ++++++++ pkg/services/queryhistory/queryhistory.go | 18 +- pkg/tests/apis/preferences/stars_test.go | 52 +++- 43 files changed, 825 insertions(+), 334 deletions(-) create mode 100644 apps/preferences/pkg/apis/preferences/v1alpha1/stars.go create mode 100644 apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go create mode 100644 pkg/registry/apis/preferences/admission.go rename pkg/registry/apis/preferences/legacy/{sql_stars_query.sql => sql_dashboard_stars.sql} (100%) rename pkg/registry/apis/preferences/legacy/{sql_stars_rv.sql => sql_dashboard_stars_rv.sql} (100%) create mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars.sql create mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql create mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql rename pkg/registry/apis/preferences/legacy/testdata/{mysql--sql_stars_query-all.sql => mysql--sql_dashboard_stars-all.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{mysql--sql_stars_query-org.sql => mysql--sql_dashboard_stars-org.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{mysql--sql_stars_query-user.sql => mysql--sql_dashboard_stars-user.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{mysql--sql_stars_rv-get.sql => mysql--sql_dashboard_stars_rv-get.sql} (100%) create mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql rename pkg/registry/apis/preferences/legacy/testdata/{postgres--sql_stars_query-all.sql => postgres--sql_dashboard_stars-all.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{postgres--sql_stars_query-org.sql => postgres--sql_dashboard_stars-org.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{postgres--sql_stars_query-user.sql => postgres--sql_dashboard_stars-user.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{postgres--sql_stars_rv-get.sql => postgres--sql_dashboard_stars_rv-get.sql} (100%) create mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql rename pkg/registry/apis/preferences/legacy/testdata/{sqlite--sql_stars_query-all.sql => sqlite--sql_dashboard_stars-all.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{sqlite--sql_stars_query-org.sql => sqlite--sql_dashboard_stars-org.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{sqlite--sql_stars_query-user.sql => sqlite--sql_dashboard_stars-user.sql} (100%) rename pkg/registry/apis/preferences/legacy/testdata/{sqlite--sql_stars_rv-get.sql => sqlite--sql_dashboard_stars_rv-get.sql} (100%) create mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql create mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql create mode 100644 pkg/services/queryhistory/client.go diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index f21e6a19526..3c4b1af454b 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -5,6 +5,7 @@ go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.46.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 + github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.1 k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b ) @@ -42,7 +43,6 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go b/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go new file mode 100644 index 00000000000..e203e6b560e --- /dev/null +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go @@ -0,0 +1,80 @@ +package v1alpha1 + +import ( + "slices" + "strings" +) + +func (stars *StarsSpec) Add(group, kind, name string) { + for i, r := range stars.Resource { + if r.Group == group && r.Kind == kind { + r.Names = append(r.Names, name) + slices.Sort(r.Names) + stars.Resource[i].Names = slices.Compact(r.Names) + return + } + } + + // Add the resource kind + stars.Resource = append(stars.Resource, StarsResource{ + Group: group, + Kind: kind, + Names: []string{name}, + }) + stars.Normalize() +} + +func (stars *StarsSpec) Remove(group, kind, name string) { + for i, r := range stars.Resource { + if r.Group == group && r.Kind == kind { + idx := slices.Index(r.Names, name) + if idx < 0 { + return // does not exist + } + r.Names = append(r.Names[:idx], r.Names[idx+1:]...) + stars.Resource[i].Names = r.Names + if len(r.Names) == 0 { + stars.Normalize() + } + return + } + } +} + +// Makes sure everything is in sorted order +func (stars *StarsSpec) Normalize() { + resources := make([]StarsResource, 0, len(stars.Resource)) + for _, r := range stars.Resource { + if len(r.Names) > 0 { + slices.Sort(r.Names) + r.Names = slices.Compact(r.Names) // removes any duplicates + resources = append(resources, r) + } + } + slices.SortFunc(resources, func(a StarsResource, b StarsResource) int { + return strings.Compare(a.Group+a.Kind, b.Group+b.Kind) + }) + if len(resources) == 0 { + resources = nil + } + stars.Resource = resources +} + +func Changes(current []string, target []string) (added []string, removed []string, same []string) { + lookup := map[string]bool{} + for _, k := range current { + lookup[k] = true + } + for _, k := range target { + if lookup[k] { + same = append(same, k) + delete(lookup, k) + } else { + added = append(added, k) + } + } + for k := range lookup { + removed = append(removed, k) + } + return +} diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go b/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go new file mode 100644 index 00000000000..5713b07a751 --- /dev/null +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go @@ -0,0 +1,235 @@ +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type starItem struct { + group string + kind string + name string +} + +func TestStarsWrite(t *testing.T) { + t.Run("apply", func(t *testing.T) { + tests := []struct { + name string + spec *StarsSpec + item starItem + remove bool + expect *StarsSpec + }{{ + name: "add to an existing array", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "c", + }, + remove: false, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c", "x"}, // added "b" (and sorted) + }}, + }, + }, { + name: "remove from an existing array", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "b", + }, + remove: true, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "c"}, // removed "b" + }}, + }, + }, { + name: "add to empty spec", + spec: &StarsSpec{}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: false, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }}, + }, + }, { + name: "remove item that does not exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + }, { + name: "add item that already exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "x", + }, + remove: false, + }, { + name: "remove from empty", + spec: &StarsSpec{}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + }, { + name: "remove item that does not exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "X", + }, + remove: true, + }, { + name: "remove last item", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + expect: &StarsSpec{}, // empty object + }, { + name: "remove last item (with others)", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }, { + Group: "g2", + Kind: "k2", + Names: []string{"a"}, + }}}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g2", + Kind: "k2", + Names: []string{"a"}, + }}}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expect == nil { + tt.expect = tt.spec.DeepCopy() + } + + if tt.remove { + tt.spec.Remove(tt.item.group, tt.item.kind, tt.item.name) + } else { + tt.spec.Add(tt.item.group, tt.item.kind, tt.item.name) + } + + require.Equal(t, tt.expect, tt.spec) + }) + } + }) + + t.Run("changes", func(t *testing.T) { + tests := []struct { + name string + current []string + target []string + added []string + removed []string + same []string + }{{ + name: "same", + current: []string{"a"}, + target: []string{"a"}, + same: []string{"a"}, + }, { + name: "adding one", + current: []string{"a"}, + target: []string{"a", "b"}, + same: []string{"a"}, + added: []string{"b"}, + }, { + name: "removing one", + current: []string{"a", "b"}, + target: []string{"a"}, + same: []string{"a"}, + removed: []string{"b"}, + }, { + name: "removed to empty", + current: []string{"a"}, + target: []string{}, + removed: []string{"a"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a, r, s := Changes(tt.current, tt.target) + require.Equal(t, tt.added, a, "added") + require.Equal(t, tt.removed, r, "removed") + require.Equal(t, tt.same, s, "same") + }) + } + }) +} diff --git a/pkg/registry/apis/preferences/admission.go b/pkg/registry/apis/preferences/admission.go new file mode 100644 index 00000000000..0703b37b8df --- /dev/null +++ b/pkg/registry/apis/preferences/admission.go @@ -0,0 +1,35 @@ +package preferences + +import ( + "context" + "fmt" + + "k8s.io/apiserver/pkg/admission" + + preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" +) + +func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { + switch a.GetOperation() { + case admission.Create, admission.Update: + // ignore anything that is not CREATE | UPDATE + default: + return nil + } + + obj := a.GetObject() + if obj == nil { + return nil + } + + switch a.GetResource().Resource { + case "stars": + stars, ok := obj.(*preferences.Stars) + if !ok { + return fmt.Errorf("expected stars object: (%T)", obj) + } + stars.Spec.Normalize() + return nil + } + return nil +} diff --git a/pkg/registry/apis/preferences/legacy/queries.go b/pkg/registry/apis/preferences/legacy/queries.go index dd3826d9674..d51904a0601 100644 --- a/pkg/registry/apis/preferences/legacy/queries.go +++ b/pkg/registry/apis/preferences/legacy/queries.go @@ -26,21 +26,29 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlStarsQuery = mustTemplate("sql_stars_query.sql") - sqlStarsRV = mustTemplate("sql_stars_rv.sql") - sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") - sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") - sqlTeams = mustTemplate("sql_teams.sql") + sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql") + sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql") + sqlHistoryStarsQuery = mustTemplate("sql_history_stars.sql") + sqlHistoryStarsInsert = mustTemplate("sql_history_stars_insert.sql") + sqlHistoryStarsDelete = mustTemplate("sql_history_stars_delete.sql") + sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") + sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") + sqlTeams = mustTemplate("sql_teams.sql") ) type starQuery struct { sqltemplate.SQLTemplate - OrgID int64 // >= 1 if UserID != "" - UserUID string + OrgID int64 // >= 1 if UserID != "" + UserUID string + UserID int64 // for stars + QueryUIDs []string + QueryUID string - StarTable string - UserTable string + StarTable string + UserTable string + QueryHistoryStarsTable string + QueryHistoryTable string } func (r starQuery) Validate() error { @@ -57,8 +65,10 @@ func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int UserUID: user, OrgID: orgId, - StarTable: sql.Table("star"), - UserTable: sql.Table("user"), + StarTable: sql.Table("star"), + UserTable: sql.Table("user"), + QueryHistoryStarsTable: sql.Table("query_history_star"), + QueryHistoryTable: sql.Table("query_history"), } } diff --git a/pkg/registry/apis/preferences/legacy/queries_test.go b/pkg/registry/apis/preferences/legacy/queries_test.go index e1beb8ef056..726049af7f5 100644 --- a/pkg/registry/apis/preferences/legacy/queries_test.go +++ b/pkg/registry/apis/preferences/legacy/queries_test.go @@ -23,6 +23,15 @@ func TestStarsQueries(t *testing.T) { return &v } + getHistoryReq := func(orgId int64, userId int64, stars []string, star string) sqltemplate.SQLTemplate { + v := newStarQueryReq(nodb, "", orgId) + v.UserID = userId + v.QueryUIDs = stars + v.QueryUID = star + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + getPreferencesQuery := func(orgId int64, cb func(q *preferencesQuery)) sqltemplate.SQLTemplate { v := newPreferencesQueryReq(nodb, orgId) v.SQLTemplate = mocks.NewTestingSQLTemplate() @@ -40,7 +49,7 @@ func TestStarsQueries(t *testing.T) { RootDir: "testdata", SQLTemplatesFS: sqlTemplatesFS, Templates: map[*template.Template][]mocks.TemplateTestCase{ - sqlStarsQuery: { + sqlDashboardStarsQuery: { { Name: "all", Data: getStarQuery(0, ""), @@ -54,12 +63,42 @@ func TestStarsQueries(t *testing.T) { Data: getStarQuery(3, "abc"), }, }, - sqlStarsRV: { + sqlDashboardStarsRV: { { Name: "get", Data: getStarQuery(0, ""), }, }, + sqlHistoryStarsQuery: { + { + Name: "user", + Data: getStarQuery(1, "abc"), + }, + }, + sqlHistoryStarsQuery: { + { + Name: "org", + Data: getStarQuery(1, ""), + }, + }, + sqlHistoryStarsInsert: { + { + Name: "add star", + Data: getHistoryReq(1, 3, nil, "XXX"), + }, + }, + sqlHistoryStarsDelete: { + { + Name: "remove star", + Data: getHistoryReq(1, 3, []string{"xxx", "yyy"}, ""), + }, + }, + sqlHistoryStarsDelete: { + { + Name: "remove all star", + Data: getHistoryReq(1, 3, nil, ""), + }, + }, sqlPreferencesQuery: { { Name: "all", diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go index fb4cc60fcbf..93f4ea39949 100644 --- a/pkg/registry/apis/preferences/legacy/sql.go +++ b/pkg/registry/apis/preferences/legacy/sql.go @@ -12,6 +12,7 @@ import ( preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -58,13 +59,16 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str req := newStarQueryReq(sql, user, orgId) - q, err := sqltemplate.Execute(sqlStarsQuery, req) + q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req) if err != nil { - return nil, 0, fmt.Errorf("execute template %q: %w", sqlStarsQuery.Name(), err) + return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err) } sess := sql.DB.GetSqlxSession() rows, err := sess.Query(ctx, q, req.GetArgs()...) + if err != nil { + return nil, 0, err + } defer func() { if rows != nil { _ = rows.Close() @@ -111,7 +115,7 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str // Find the RV unless it is a user query if userUID == "" { req.Reset() - q, err = sqltemplate.Execute(sqlStarsRV, req) + q, err = sqltemplate.Execute(sqlDashboardStarsRV, req) if err != nil { return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesRV.Name(), err) } @@ -132,6 +136,90 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str return stars, updated.UnixMilli(), err } +func (s *LegacySQL) getHistoryStars(ctx context.Context, orgId int64, user string) (map[string][]string, error) { + sql, err := s.db(ctx) + if err != nil { + return nil, err + } + req := newStarQueryReq(sql, user, orgId) + + q, err := sqltemplate.Execute(sqlHistoryStarsQuery, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlHistoryStarsQuery.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + rows, err := sess.Query(ctx, q, req.GetArgs()...) + if err != nil { + return nil, err + } + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + + last := user + res := make(map[string][]string) + buffer := make([]string, 0, 10) + var uid string + + for rows.Next() { + err := rows.Scan(&uid, &user) + if err != nil { + return nil, err + } + if user != last && len(buffer) > 0 { + res[last] = buffer + buffer = make([]string, 0, 10) + } + buffer = append(buffer, uid) + last = user + } + res[last] = buffer + return res, nil +} + +func (s *LegacySQL) removeHistoryStar(ctx context.Context, user *user.User, stars []string) error { + sql, err := s.db(ctx) + if err != nil { + return err + } + req := newStarQueryReq(sql, "", user.OrgID) + req.UserID = user.ID + if len(stars) > 0 { + req.QueryUIDs = stars + } + + q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + _, err = sess.Exec(ctx, q, req.GetArgs()...) + return err +} + +func (s *LegacySQL) addHistoryStar(ctx context.Context, user *user.User, star string) error { + sql, err := s.db(ctx) + if err != nil { + return err + } + req := newStarQueryReq(sql, "", user.OrgID) + req.UserID = user.ID + req.QueryUID = star + + q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + _, err = sess.Exec(ctx, q, req.GetArgs()...) + return err +} + // List all defined preferences in an org (valid for admin users only) func (s *LegacySQL) listPreferences(ctx context.Context, ns string, orgId int64, diff --git a/pkg/registry/apis/preferences/legacy/sql_stars_query.sql b/pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_stars_query.sql rename to pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql diff --git a/pkg/registry/apis/preferences/legacy/sql_stars_rv.sql b/pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_stars_rv.sql rename to pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars.sql new file mode 100644 index 00000000000..93f59103707 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars.sql @@ -0,0 +1,9 @@ +SELECT s.query_uid, u.uid as user_uid + FROM {{ .Ident .QueryHistoryStarsTable }} as s + JOIN {{ .Ident .QueryHistoryTable }} as h ON s.query_uid = h.uid + JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id + WHERE s.org_id = {{ .Arg .OrgID }} + {{ if .UserUID }} + AND u.uid = {{ .Arg .UserUID }} +{{ end }} + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql new file mode 100644 index 00000000000..4804740a42f --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql @@ -0,0 +1,6 @@ +DELETE FROM {{ .Ident .QueryHistoryStarsTable }} + WHERE org_id = {{ .Arg .OrgID }} + AND user_id = {{ .Arg .UserID }} + {{ if .QueryUIDs }} + AND query_uid IN ({{ .ArgList .QueryUIDs }}) +{{ end }} \ No newline at end of file diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql new file mode 100644 index 00000000000..a85d14f2ed9 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql @@ -0,0 +1,4 @@ +INSERT INTO {{ .Ident .QueryHistoryStarsTable }} + ( query_uid, user_id, org_id ) +VALUES + ( {{ .Arg .QueryUID }}, {{ .Arg .UserID }}, {{ .Arg .OrgID }} ) diff --git a/pkg/registry/apis/preferences/legacy/stars.go b/pkg/registry/apis/preferences/legacy/stars.go index b048c249170..f38f2fd786b 100644 --- a/pkg/registry/apis/preferences/legacy/stars.go +++ b/pkg/registry/apis/preferences/legacy/stars.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "slices" "strconv" "strings" "time" @@ -12,6 +13,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/utils/ptr" @@ -107,8 +109,13 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi if err != nil { return nil, err } + history, err := s.sql.getHistoryStars(ctx, ns.OrgID, "") + if err != nil { + return nil, err + } for _, v := range found { - list.Items = append(list.Items, asStarsResource(s.namespacer(v.OrgID), &v)) + list.Items = append(list.Items, + asStarsResource(s.namespacer(v.OrgID), &v, history[v.UserUID])) } if rv > 0 { list.ResourceVersion = strconv.FormatInt(rv, 10) @@ -141,19 +148,25 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m if err != nil { return nil, err } + + history, err := s.sql.getHistoryStars(ctx, ns.OrgID, owner.Identifier) + if err != nil { + return nil, err + } + if len(found) == 0 || len(found[0].Dashboards) == 0 { return nil, apiserrors.NewNotFound(preferences.StarsResourceInfo.GroupResource(), name) } - obj := asStarsResource(ns.Value, &found[0]) + obj := asStarsResource(ns.Value, &found[0], history[owner.Identifier]) return &obj, nil } -func getDashboardStars(stars *preferences.Stars) []string { +func getStars(stars *preferences.Stars, gk schema.GroupKind) []string { if stars == nil || len(stars.Spec.Resource) == 0 { return []string{} } for _, r := range stars.Spec.Resource { - if r.Group == "dashboard.grafana.app" && r.Kind == "Dashboard" { + if r.Group == gk.Group && r.Kind == gk.Kind { return r.Names } } @@ -161,7 +174,7 @@ func getDashboardStars(stars *preferences.Stars) []string { } // Create implements rest.Creater. -func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars, old *preferences.Stars) (runtime.Object, error) { +func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars) (runtime.Object, error) { ns, owner, err := getNamespaceAndOwner(ctx, obj.Name) if err != nil { return nil, err @@ -177,7 +190,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star return nil, fmt.Errorf("namespace mismatch") } - stars := getDashboardStars(obj) + stars := getStars(obj, schema.GroupKind{Group: "dashboard.grafana.app", Kind: "Dashboard"}) if len(stars) == 0 { err = s.stars.DeleteByUser(ctx, user.ID) return &preferences.Stars{ObjectMeta: metav1.ObjectMeta{ @@ -232,6 +245,31 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star changed = true } + // Apply history stars + stars = getStars(obj, schema.GroupKind{Group: "history.grafana.app", Kind: "Query"}) + res, err := s.sql.getHistoryStars(ctx, user.OrgID, user.UID) + if err != nil { + return nil, err + } + history := res[user.UID] + if !slices.Equal(stars, history) { + changed = true + if len(stars) == 0 { + err = s.sql.removeHistoryStar(ctx, user, nil) + if err != nil { + return nil, err + } + } else { + added, removed, _ := preferences.Changes(history, stars) + if len(removed) > 0 { + _ = s.sql.removeHistoryStar(ctx, user, nil) + } + for _, v := range added { + _ = s.sql.addHistoryStar(ctx, user, v) // one at a time so duplicates do not fail everything + } + } + } + if changed { return s.Get(ctx, obj.Name, &metav1.GetOptions{}) } @@ -245,7 +283,7 @@ func (s *DashboardStarsStorage) Create(ctx context.Context, obj runtime.Object, return nil, fmt.Errorf("expected stars object") } - return s.write(ctx, stars, nil) + return s.write(ctx, stars) } // Update implements rest.Updater. @@ -265,13 +303,13 @@ func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo return nil, false, fmt.Errorf("expected stars object") } - obj, err = s.write(ctx, stars, old.(*preferences.Stars)) + obj, err = s.write(ctx, stars) return obj, false, err } // Delete implements rest.GracefulDeleter. func (s *DashboardStarsStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil) + obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}) if err != nil { return nil, false, err } @@ -283,8 +321,8 @@ func (s *DashboardStarsStorage) DeleteCollection(ctx context.Context, deleteVali return nil, fmt.Errorf("not implemented yet") } -func asStarsResource(ns string, v *dashboardStars) preferences.Stars { - return preferences.Stars{ +func asStarsResource(ns string, v *dashboardStars, history []string) preferences.Stars { + stars := preferences.Stars{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("user-%s", v.UserUID), Namespace: ns, @@ -299,4 +337,13 @@ func asStarsResource(ns string, v *dashboardStars) preferences.Stars { }}, }, } + if len(history) > 0 { + stars.Spec.Resource = append(stars.Spec.Resource, preferences.StarsResource{ + Group: "history.grafana.app", + Kind: "Query", + Names: history, + }) + } + stars.Spec.Normalize() + return stars } diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql new file mode 100755 index 00000000000..e651c6bc37c --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM `grafana`.`query_history_star` as s + JOIN `grafana`.`query_history` as h ON s.query_uid = h.uid + JOIN `grafana`.`user` as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0c18ffdc0a8 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM `grafana`.`query_history_star` + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..5a050187451 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO `grafana`.`query_history_star` + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql new file mode 100755 index 00000000000..d0fe6688c97 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM "grafana"."query_history_star" as s + JOIN "grafana"."query_history" as h ON s.query_uid = h.uid + JOIN "grafana"."user" as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0b31729f72e --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM "grafana"."query_history_star" + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..d4729521f0d --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO "grafana"."query_history_star" + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql new file mode 100755 index 00000000000..d0fe6688c97 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM "grafana"."query_history_star" as s + JOIN "grafana"."query_history" as h ON s.query_uid = h.uid + JOIN "grafana"."user" as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0b31729f72e --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM "grafana"."query_history_star" + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..d4729521f0d --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO "grafana"."query_history_star" + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index ff186c6bdb2..786d200d7cd 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -29,7 +29,10 @@ import ( "github.com/grafana/grafana/pkg/storage/legacysql" ) -var _ builder.APIGroupBuilder = (*APIBuilder)(nil) +var ( + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupMutation = (*APIBuilder)(nil) +) type APIBuilder struct { authorizer authorizer.Authorizer @@ -112,7 +115,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI if err != nil { return err } - stars = &starStorage{store: stars} // wrap List so we only return one value + stars = &starStorage{Storage: stars} // wrap List so we only return one value if b.legacyStars != nil && opts.DualWriteBuilder != nil { stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars) if err != nil { diff --git a/pkg/registry/apis/preferences/stars.go b/pkg/registry/apis/preferences/stars.go index 68bbb5c61bd..b5c8d0b0dcf 100644 --- a/pkg/registry/apis/preferences/stars.go +++ b/pkg/registry/apis/preferences/stars.go @@ -6,7 +6,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" authlib "github.com/grafana/authlib/types" preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" @@ -17,7 +16,7 @@ import ( var _ grafanarest.Storage = (*starStorage)(nil) type starStorage struct { - store grafanarest.Storage + grafanarest.Storage } // When using list, we really just want to get the value for the single user @@ -34,7 +33,7 @@ func (s *starStorage) List(ctx context.Context, options *internalversion.ListOpt // Get the single user stars case authlib.TypeUser: stars := &preferences.StarsList{} - obj, _ := s.store.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{}) + obj, _ := s.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{}) if obj != nil { s, ok := obj.(*preferences.Stars) if ok { @@ -44,61 +43,6 @@ func (s *starStorage) List(ctx context.Context, options *internalversion.ListOpt return stars, nil default: - return s.store.List(ctx, options) + return s.Storage.List(ctx, options) } } - -// ConvertToTable implements rest.Storage. -func (s *starStorage) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*v1.Table, error) { - return s.store.ConvertToTable(ctx, obj, tableOptions) -} - -// Create implements rest.Storage. -func (s *starStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *v1.CreateOptions) (runtime.Object, error) { - return s.store.Create(ctx, obj, createValidation, options) -} - -// Delete implements rest.Storage. -func (s *starStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *v1.DeleteOptions) (runtime.Object, bool, error) { - return s.store.Delete(ctx, name, deleteValidation, options) -} - -// DeleteCollection implements rest.Storage. -func (s *starStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *v1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { - return s.store.DeleteCollection(ctx, deleteValidation, options, listOptions) -} - -// Destroy implements rest.Storage. -func (s *starStorage) Destroy() { - s.store.Destroy() -} - -// Get implements rest.Storage. -func (s *starStorage) Get(ctx context.Context, name string, options *v1.GetOptions) (runtime.Object, error) { - return s.store.Get(ctx, name, options) -} - -// GetSingularName implements rest.Storage. -func (s *starStorage) GetSingularName() string { - return s.store.GetSingularName() -} - -// NamespaceScoped implements rest.Storage. -func (s *starStorage) NamespaceScoped() bool { - return s.store.NamespaceScoped() -} - -// New implements rest.Storage. -func (s *starStorage) New() runtime.Object { - return s.store.New() -} - -// NewList implements rest.Storage. -func (s *starStorage) NewList() runtime.Object { - return s.store.NewList() -} - -// Update implements rest.Storage. -func (s *starStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *v1.UpdateOptions) (runtime.Object, bool, error) { - return s.store.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) -} diff --git a/pkg/registry/apis/preferences/stars_update.go b/pkg/registry/apis/preferences/stars_update.go index bf3a4c0241d..2280a448e07 100644 --- a/pkg/registry/apis/preferences/stars_update.go +++ b/pkg/registry/apis/preferences/stars_update.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "slices" "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -110,11 +109,10 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, return } - if !apply(&obj.Spec, item, remove) { - responder.Object(http.StatusNoContent, &v1.Status{ - Code: http.StatusNoContent, - }) - return + if remove { + obj.Spec.Remove(item.group, item.kind, item.id) + } else { + obj.Spec.Add(item.group, item.kind, item.id) } if len(obj.Spec.Resource) == 0 { @@ -128,9 +126,7 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, responder.Error(err) return } - responder.Object(http.StatusOK, &v1.Status{ - Code: http.StatusOK, - }) + responder.Object(http.StatusOK, &v1.Status{Code: http.StatusOK}) }), nil } @@ -151,49 +147,3 @@ func itemFromPath(urlPath, prefix string) (starItem, error) { id: parts[2], }, nil } - -func apply(spec *preferences.StarsSpec, item starItem, remove bool) bool { - var stars *preferences.StarsResource - for idx, v := range spec.Resource { - if v.Group == item.group && v.Kind == item.kind { - stars = &spec.Resource[idx] - } - } - if stars == nil { - if remove { - return false - } - spec.Resource = append(spec.Resource, preferences.StarsResource{ - Group: item.group, - Kind: item.kind, - Names: []string{}, - }) - stars = &spec.Resource[len(spec.Resource)-1] - } - - idx := slices.Index(stars.Names, item.id) - if idx < 0 { // not found - if remove { - return false - } - stars.Names = append(stars.Names, item.id) - } else if remove { - stars.Names = append(stars.Names[:idx], stars.Names[idx+1:]...) - } else { - return false - } - slices.Sort(stars.Names) - - // Remove the slot if only one value - if len(stars.Names) == 0 { - tmp := preferences.StarsSpec{} - for _, v := range spec.Resource { - if v.Group == item.group && v.Kind == item.kind { - continue - } - tmp.Resource = append(tmp.Resource, v) - } - spec.Resource = tmp.Resource - } - return true -} diff --git a/pkg/registry/apis/preferences/stars_update_test.go b/pkg/registry/apis/preferences/stars_update_test.go index 8faf413550c..c4b58804cbc 100644 --- a/pkg/registry/apis/preferences/stars_update_test.go +++ b/pkg/registry/apis/preferences/stars_update_test.go @@ -4,194 +4,9 @@ import ( "testing" "github.com/stretchr/testify/require" - - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" ) func TestStarsWrite(t *testing.T) { - t.Run("apply", func(t *testing.T) { - tests := []struct { - name string - spec preferences.StarsSpec - item starItem - remove bool - changed bool - expect preferences.StarsSpec - }{{ - name: "add to an existing array", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "x", - }, - remove: false, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c", "x"}, // added "x" - }}, - }, - }, { - name: "remove from an existing array", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "b", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "c"}, // removed "b" - }}, - }, - }, { - name: "add to empty spec", - spec: preferences.StarsSpec{}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: false, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }}, - }, - }, { - name: "remove item that does not exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"x"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: false, - }, { - name: "add item that already exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"x"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "x", - }, - remove: false, - changed: false, - }, { - name: "remove from empty", - spec: preferences.StarsSpec{}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: false, - }, { - name: "remove item that does not exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "X", - }, - remove: true, - changed: false, - }, { - name: "remove last item", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{}, - }, { - name: "remove last item (with others)", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }, { - Group: "g2", - Kind: "k2", - Names: []string{"a"}, - }}}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g2", - Kind: "k2", - Names: []string{"a"}, - }}}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - changed := apply(&tt.spec, tt.item, tt.remove) - require.Equal(t, tt.changed, changed) - if changed { - require.Equal(t, tt.expect, tt.spec) - } - }) - } - }) - t.Run("path", func(t *testing.T) { tests := []struct { name string diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 005ee68b230..6b2be6c0da5 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -585,7 +585,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api ossProvider := guardian.ProvideGuardian() cacheServiceImpl := service9.ProvideCacheService(cacheService, sqlStore, ossProvider) shortURLService := shorturlimpl.ProvideService(sqlStore) - queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) + queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl, featureToggles, eventualRestConfigProvider) dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, featureToggles, k8sHandlerWithFallback) dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) @@ -1182,7 +1182,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } shortURLService := shorturlimpl.ProvideService(sqlStore) - queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) + queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl, featureToggles, eventualRestConfigProvider) dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, featureToggles, k8sHandlerWithFallback) dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index 0b39e4c9f30..0d26718170e 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -168,6 +168,16 @@ func (s *QueryHistoryService) starHandler(c *contextmodel.ReqContext) response.R if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } + if s.k8sClients != nil { + if err := s.k8sClients.AddStar(c, queryUID); err != nil { + return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) + } + return response.JSON(http.StatusOK, QueryHistoryResponse{ + Result: QueryHistoryDTO{ + UID: queryUID, + Starred: true, + }}) + } query, err := s.StarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { @@ -192,6 +202,16 @@ func (s *QueryHistoryService) unstarHandler(c *contextmodel.ReqContext) response if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } + if s.k8sClients != nil { + if err := s.k8sClients.RemoveStar(c, queryUID); err != nil { + return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) + } + return response.JSON(http.StatusOK, QueryHistoryResponse{ + Result: QueryHistoryDTO{ + UID: queryUID, + Starred: true, + }}) + } query, err := s.UnstarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { diff --git a/pkg/services/queryhistory/client.go b/pkg/services/queryhistory/client.go new file mode 100644 index 00000000000..a134ef9e186 --- /dev/null +++ b/pkg/services/queryhistory/client.go @@ -0,0 +1,103 @@ +package queryhistory + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + authlib "github.com/grafana/authlib/types" + preferencesV1 "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" +) + +type k8sClients struct { + namespacer authlib.NamespaceFormatter + configProvider apiserver.DirectRestConfigProvider +} + +// GetStars implements K8sClients. +func (k *k8sClients) GetStars(c *contextmodel.ReqContext) ([]string, error) { + dyn, err := dynamic.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return nil, err + } + client := dyn.Resource(preferencesV1.StarsResourceInfo.GroupVersionResource()).Namespace(k.namespacer(c.OrgID)) + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + obj, _ := client.Get(ctx, "user-"+user.GetIdentifier(), v1.GetOptions{}) + if obj != nil { + resources, ok, _ := unstructured.NestedSlice(obj.Object, "spec", "resource") + if ok && resources != nil { + for _, r := range resources { + tmp, ok := r.(map[string]any) + if ok { + g, _, _ := unstructured.NestedString(tmp, "group") + k, _, _ := unstructured.NestedString(tmp, "kind") + if k == "Query" && g == "history.grafana.app" { + names, _, _ := unstructured.NestedStringSlice(tmp, "names") + return names, nil + } + } + } + } + } + return []string{}, nil +} + +// AddStar implements K8sClients. +func (k *k8sClients) AddStar(c *contextmodel.ReqContext, uid string) error { + dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return err + } + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return err + } + + ns := k.namespacer(c.OrgID) + + client := dyn.RESTClient() + rsp := client.Put().AbsPath( + "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "stars", "user-"+user.GetIdentifier(), + "update", "history.grafana.app", "Query", uid, + ).Do(ctx) + + return rsp.Error() +} + +// RemoveStar implements K8sClients. +func (k *k8sClients) RemoveStar(c *contextmodel.ReqContext, uid string) error { + dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return err + } + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return err + } + + ns := k.namespacer(c.OrgID) + + client := dyn.RESTClient() + rsp := client.Delete().AbsPath( + "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "stars", "user-"+user.GetIdentifier(), + "update", "history.grafana.app", "Query", uid, + ).Do(ctx) + + return rsp.Error() +} diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go index d2f1e90e8bc..6e7cff4ad85 100644 --- a/pkg/services/queryhistory/queryhistory.go +++ b/pkg/services/queryhistory/queryhistory.go @@ -8,11 +8,20 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, accessControl ac.AccessControl) *QueryHistoryService { +func ProvideService(cfg *setting.Cfg, + sqlStore db.DB, + routeRegister routing.RouteRegister, + accessControl ac.AccessControl, + features featuremgmt.FeatureToggles, + configProvider apiserver.DirectRestConfigProvider, +) *QueryHistoryService { s := &QueryHistoryService{ store: sqlStore, Cfg: cfg, @@ -24,6 +33,12 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout // Register routes only when query history is enabled if s.Cfg.QueryHistoryEnabled { + if features.IsEnabledGlobally(featuremgmt.FlagKubernetesStars) { + s.k8sClients = &k8sClients{ + namespacer: request.GetNamespaceMapper(s.Cfg), + configProvider: configProvider, + } + } s.registerAPIEndpoints() } @@ -48,6 +63,7 @@ type QueryHistoryService struct { log log.Logger now func() time.Time accessControl ac.AccessControl + k8sClients *k8sClients } func (s QueryHistoryService) CreateQueryInQueryHistory(ctx context.Context, user *user.SignedInUser, cmd CreateQueryInQueryHistoryCommand) (QueryHistoryDTO, error) { diff --git a/pkg/tests/apis/preferences/stars_test.go b/pkg/tests/apis/preferences/stars_test.go index 3efc2469119..f1050593926 100644 --- a/pkg/tests/apis/preferences/stars_test.go +++ b/pkg/tests/apis/preferences/stars_test.go @@ -15,6 +15,7 @@ import ( preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -70,6 +71,17 @@ func TestIntegrationStars(t *testing.T) { GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(), }) + history := &queryhistory.QueryHistoryResponse{} + legacyHistoryResponse := apis.DoRequest(helper, apis.RequestParams{ + User: starsClient.Args.User, + Method: http.MethodPost, + Path: "/api/query-history", + Body: []byte(`{"dataSourceUid":"eez1ebbdn3pq8b","queries":[{"scenarioId":"random_walk","seriesCount":1,"refId":"A","datasource":{"type":"grafana-testdata-datasource","uid":"eez1ebbdn3pq8b","apiVersion":"v0alpha1"}}]}`), + }, &history) + require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") + queryHistoryStarUID := history.Result.UID + require.NotEmpty(t, queryHistoryStarUID, "expect a query history UID") + // Create 5 dashboards for i := range 5 { _, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{ @@ -145,7 +157,7 @@ func TestIntegrationStars(t *testing.T) { // Change stars via k8s update rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "metadata": map[string]any{ "name": "user-" + starsClient.Args.User.Identity.GetIdentifier(), "namespace": "default", @@ -169,9 +181,45 @@ func TestIntegrationStars(t *testing.T) { require.Equal(t, "dashboard.grafana.app", resources[0].Group) require.Equal(t, "Dashboard", resources[0].Kind) require.ElementsMatch(t, - []string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb + []string{"aaa", "bbb", "test-2"}, // NOTE 2 stays, 3 removed, added aaa+bbb (and sorted!) resources[0].Names) + // Query history stars + legacyHistoryResponse = apis.DoRequest(helper, apis.RequestParams{ + User: starsClient.Args.User, + Method: http.MethodPost, + Path: "/api/query-history/star/" + queryHistoryStarUID, + }, &history) + require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") + require.True(t, history.Result.Starred, "expect the value to be starred") + + rspObj, err = starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + require.NoError(t, err) + + after = typed(t, rspObj, &preferences.Stars{}) + jj, err := json.MarshalIndent(after.Spec, "", " ") + require.NoError(t, err) + require.JSONEq(t, `{ + "resource": [ + { + "group": "dashboard.grafana.app", + "kind": "Dashboard", + "names": [ + "aaa", + "bbb", + "test-2" + ] + }, + { + "group": "history.grafana.app", + "kind": "Query", + "names": [ + "`+queryHistoryStarUID+`" + ] + } + ] + }`, string(jj)) + // Viewer does not have any stars rsp, err = starsClientViewer.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err)