Dashboard Versions: Make compatible with app platform (#99327)

This commit is contained in:
Stephanie Hingtgen
2025-01-28 07:17:52 -07:00
committed by GitHub
parent 05905a5069
commit 0cef2b9ae7
44 changed files with 887 additions and 413 deletions

View File

@ -24,6 +24,7 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion" dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/guardian"
@ -833,16 +834,17 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
DashboardUID: dash.UID, DashboardUID: dash.UID,
Limit: c.QueryInt("limit"), Limit: c.QueryInt("limit"),
Start: c.QueryInt("start"), Start: c.QueryInt("start"),
ContinueToken: c.Query("continueToken"),
} }
versions, err := hs.dashboardVersionService.List(c.Req.Context(), &query) resp, err := hs.dashboardVersionService.List(c.Req.Context(), &query)
if err != nil { if err != nil {
return response.Error(http.StatusNotFound, fmt.Sprintf("No versions found for dashboardId %d", dash.ID), err) return response.Error(http.StatusNotFound, fmt.Sprintf("No versions found for dashboardId %d", dash.ID), err)
} }
loginMem := make(map[int64]string, len(versions)) loginMem := make(map[int64]string, len(resp.Versions))
res := make([]dashver.DashboardVersionMeta, 0, len(versions)) res := make([]dashver.DashboardVersionMeta, 0, len(resp.Versions))
for _, version := range versions { for _, version := range resp.Versions {
msg := version.Message msg := version.Message
if version.RestoredFrom == version.Version { if version.RestoredFrom == version.Version {
msg = "Initial save (created by migration)" msg = "Initial save (created by migration)"
@ -883,7 +885,10 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
}) })
} }
return response.JSON(http.StatusOK, res) return response.JSON(http.StatusOK, dashver.DashboardVersionResponseMeta{
Versions: res,
ContinueToken: resp.ContinueToken,
})
} }
// swagger:route GET /dashboards/id/{DashboardID}/versions/{DashboardVersionID} dashboard_versions getDashboardVersionByID // swagger:route GET /dashboards/id/{DashboardID}/versions/{DashboardVersionID} dashboard_versions getDashboardVersionByID
@ -943,12 +948,15 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R
return dashboardGuardianResponse(err) return dashboardGuardianResponse(err)
} }
version, _ := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 32) version, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Err(err)
}
query := dashver.GetDashboardVersionQuery{ query := dashver.GetDashboardVersionQuery{
OrgID: c.SignedInUser.GetOrgID(), OrgID: c.SignedInUser.GetOrgID(),
DashboardID: dash.ID, DashboardID: dash.ID,
DashboardUID: dash.UID, DashboardUID: dash.UID,
Version: int(version), Version: version,
} }
res, err := hs.dashboardVersionService.Get(c.Req.Context(), &query) res, err := hs.dashboardVersionService.Get(c.Req.Context(), &query)
@ -1158,7 +1166,7 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon
saveCmd.Dashboard = version.Data saveCmd.Dashboard = version.Data
saveCmd.Dashboard.Set("version", dash.Version) saveCmd.Dashboard.Set("version", dash.Version)
saveCmd.Dashboard.Set("uid", dash.UID) saveCmd.Dashboard.Set("uid", dash.UID)
saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version) saveCmd.Message = dashverimpl.DashboardRestoreMessage(version.Version)
// nolint:staticcheck // nolint:staticcheck
saveCmd.FolderID = dash.FolderID saveCmd.FolderID = dash.FolderID
metrics.MFolderIDsAPICount.WithLabelValues(metrics.RestoreDashboardVersion).Inc() metrics.MFolderIDsAPICount.WithLabelValues(metrics.RestoreDashboardVersion).Inc()

View File

@ -743,10 +743,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}).callGetDashboardVersions(sc) }).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code) assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions) err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err) require.NoError(t, err)
for _, v := range versions { for _, v := range versions.Versions {
assert.Equal(t, "test-user", v.CreatedBy) assert.Equal(t, "test-user", v.CreatedBy)
} }
}, mockSQLStore) }, mockSQLStore)
@ -769,10 +769,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}).callGetDashboardVersions(sc) }).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code) assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions) err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err) require.NoError(t, err)
for _, v := range versions { for _, v := range versions.Versions {
assert.Equal(t, anonString, v.CreatedBy) assert.Equal(t, anonString, v.CreatedBy)
} }
}, mockSQLStore) }, mockSQLStore)
@ -795,10 +795,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}).callGetDashboardVersions(sc) }).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code) assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions) err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err) require.NoError(t, err)
for _, v := range versions { for _, v := range versions.Versions {
assert.Equal(t, anonString, v.CreatedBy) assert.Equal(t, anonString, v.CreatedBy)
} }
}, mockSQLStore) }, mockSQLStore)

View File

@ -54,10 +54,10 @@ type CalculateDiffOptions struct {
type CalculateDiffTarget struct { type CalculateDiffTarget struct {
DashboardId int64 `json:"dashboardId"` DashboardId int64 `json:"dashboardId"`
Version int `json:"version"` Version int64 `json:"version"`
UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"` UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"`
} }
type RestoreDashboardVersionCommand struct { type RestoreDashboardVersionCommand struct {
Version int `json:"version" binding:"Required"` Version int64 `json:"version" binding:"Required"`
} }

View File

@ -34,7 +34,7 @@ type Options struct {
type DiffTarget struct { type DiffTarget struct {
DashboardId int64 DashboardId int64
Version int Version int64
UnsavedDashboard *simplejson.Json UnsavedDashboard *simplejson.Json
} }

View File

@ -30,11 +30,11 @@ WHERE dashboard.is_folder = {{ .Arg .Query.GetFolders }}
{{ if .Query.Version }} {{ if .Query.Version }}
AND dashboard_version.version = {{ .Arg .Query.Version }} AND dashboard_version.version = {{ .Arg .Query.Version }}
{{ else if .Query.LastID }} {{ else if .Query.LastID }}
AND dashboard_version.version < {{ .Arg .Query.LastID }} AND dashboard_version.version <= {{ .Arg .Query.LastID }}
{{ end }} {{ end }}
ORDER BY ORDER BY
dashboard_version.created ASC, dashboard_version.created DESC,
dashboard_version.version ASC, dashboard_version.version DESC,
dashboard.uid ASC dashboard.uid ASC
{{ else }} {{ else }}
{{ if .Query.UID }} {{ if .Query.UID }}

View File

@ -117,6 +117,7 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyD
return &rowsWrapper{ return &rowsWrapper{
rows: rows, rows: rows,
a: a, a: a,
history: query.GetHistory,
// This looks up rules from the permissions on a user // This looks up rules from the permissions on a user
canReadDashboard: func(scopes ...string) bool { canReadDashboard: func(scopes ...string) bool {
return true // ??? return true // ???
@ -130,6 +131,7 @@ var _ resource.ListIterator = (*rowsWrapper)(nil)
type rowsWrapper struct { type rowsWrapper struct {
a *dashboardSqlAccess a *dashboardSqlAccess
rows *sql.Rows rows *sql.Rows
history bool
canReadDashboard func(scopes ...string) bool canReadDashboard func(scopes ...string) bool
@ -157,7 +159,7 @@ func (r *rowsWrapper) Next() bool {
// breaks after first readable value // breaks after first readable value
for r.rows.Next() { for r.rows.Next() {
r.row, err = r.a.scanRow(r.rows) r.row, err = r.a.scanRow(r.rows, r.history)
if err != nil { if err != nil {
r.err = err r.err = err
return false return false
@ -187,6 +189,11 @@ func (r *rowsWrapper) ContinueToken() string {
return r.row.token.String() return r.row.token.String()
} }
// ContinueTokenWithCurrentRV implements resource.ListIterator.
func (r *rowsWrapper) ContinueTokenWithCurrentRV() string {
return r.row.token.String()
}
// Error implements resource.ListIterator. // Error implements resource.ListIterator.
func (r *rowsWrapper) Error() error { func (r *rowsWrapper) Error() error {
return r.err return r.err
@ -218,7 +225,7 @@ func (r *rowsWrapper) Value() []byte {
return b return b
} }
func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRow, error) {
dash := &dashboard.Dashboard{ dash := &dashboard.Dashboard{
TypeMeta: dashboard.DashboardResourceInfo.TypeMeta(), TypeMeta: dashboard.DashboardResourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{Annotations: make(map[string]string)}, ObjectMeta: metav1.ObjectMeta{Annotations: make(map[string]string)},
@ -255,8 +262,12 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) {
) )
row.token = &continueToken{orgId: orgId, id: dashboard_id} row.token = &continueToken{orgId: orgId, id: dashboard_id}
// when listing from the history table, we want to use the version as the ID to continue from
if history {
row.token.id = version
}
if err == nil { if err == nil {
row.RV = getResourceVersion(dashboard_id, version) row.RV = version
dash.ResourceVersion = fmt.Sprintf("%d", row.RV) dash.ResourceVersion = fmt.Sprintf("%d", row.RV)
dash.Namespace = a.namespacer(orgId) dash.Namespace = a.namespacer(orgId)
dash.UID = gapiutil.CalculateClusterWideUID(dash) dash.UID = gapiutil.CalculateClusterWideUID(dash)
@ -405,6 +416,7 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
} }
out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
OrgID: orgId, OrgID: orgId,
Message: meta.GetMessage(),
PluginID: service.GetPluginIDFromMeta(meta), PluginID: service.GetPluginIDFromMeta(meta),
Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()), Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()),
FolderUID: meta.GetFolder(), FolderUID: meta.GetFolder(),

View File

@ -138,7 +138,7 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resource.Rea
} }
version := int64(0) version := int64(0)
if req.ResourceVersion > 0 { if req.ResourceVersion > 0 {
version = getVersionFromRV(req.ResourceVersion) version = req.ResourceVersion
} }
dash, rv, err := a.GetDashboard(ctx, info.OrgID, req.Key.Name, version) dash, rv, err := a.GetDashboard(ctx, info.OrgID, req.Key.Name, version)

View File

@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE
AND dashboard.uid = 'UUU' AND dashboard.uid = 'UUU'
AND dashboard_version.version = 3 AND dashboard_version.version = 3
ORDER BY ORDER BY
dashboard_version.created ASC, dashboard_version.created DESC,
dashboard_version.version ASC, dashboard_version.version DESC,
dashboard.uid ASC dashboard.uid ASC

View File

@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE
AND dashboard.uid = 'UUU' AND dashboard.uid = 'UUU'
AND dashboard_version.version = 3 AND dashboard_version.version = 3
ORDER BY ORDER BY
dashboard_version.created ASC, dashboard_version.created DESC,
dashboard_version.version ASC, dashboard_version.version DESC,
dashboard.uid ASC dashboard.uid ASC

View File

@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE
AND dashboard.uid = 'UUU' AND dashboard.uid = 'UUU'
AND dashboard_version.version = 3 AND dashboard_version.version = 3
ORDER BY ORDER BY
dashboard_version.created ASC, dashboard_version.created DESC,
dashboard_version.version ASC, dashboard_version.version DESC,
dashboard.uid ASC dashboard.uid ASC

View File

@ -1,9 +0,0 @@
package legacy
func getResourceVersion(id int64, version int64) int64 {
return version + (id * 10000000)
}
func getVersionFromRV(rv int64) int64 {
return rv % 10000000
}

View File

@ -1,13 +0,0 @@
package legacy
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestVersionHacks(t *testing.T) {
rv := getResourceVersion(123, 456)
require.Equal(t, int64(1230000456), rv)
require.Equal(t, int64(456), getVersionFromRV(rv))
}

View File

@ -2,7 +2,10 @@ package client
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -16,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
k8sUser "k8s.io/apiserver/pkg/authentication/user" k8sUser "k8s.io/apiserver/pkg/authentication/user"
@ -24,7 +28,7 @@ import (
type K8sHandler interface { type K8sHandler interface {
GetNamespace(orgID int64) string GetNamespace(orgID int64) string
Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error)
Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error)
Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error)
Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error
@ -32,6 +36,7 @@ type K8sHandler interface {
List(ctx context.Context, orgID int64, options v1.ListOptions) (*unstructured.UnstructuredList, error) List(ctx context.Context, orgID int64, options v1.ListOptions) (*unstructured.UnstructuredList, error)
Search(ctx context.Context, orgID int64, in *resource.ResourceSearchRequest) (*resource.ResourceSearchResponse, error) Search(ctx context.Context, orgID int64, in *resource.ResourceSearchRequest) (*resource.ResourceSearchResponse, error)
GetStats(ctx context.Context, orgID int64) (*resource.ResourceStatsResponse, error) GetStats(ctx context.Context, orgID int64) (*resource.ResourceStatsResponse, error)
GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error)
} }
var _ K8sHandler = (*k8sHandler)(nil) var _ K8sHandler = (*k8sHandler)(nil)
@ -41,9 +46,10 @@ type k8sHandler struct {
gvr schema.GroupVersionResource gvr schema.GroupVersionResource
restConfigProvider apiserver.RestConfigProvider restConfigProvider apiserver.RestConfigProvider
searcher resource.ResourceIndexClient searcher resource.ResourceIndexClient
userService user.Service
} }
func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store) K8sHandler { func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store, userSvc user.Service) K8sHandler {
legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore) legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore)
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, searcher, legacySearcher) searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, searcher, legacySearcher)
return &k8sHandler{ return &k8sHandler{
@ -51,6 +57,7 @@ func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr sch
gvr: gvr, gvr: gvr,
restConfigProvider: restConfigProvider, restConfigProvider: restConfigProvider,
searcher: searchClient, searcher: searchClient,
userService: userSvc,
} }
} }
@ -58,7 +65,7 @@ func (h *k8sHandler) GetNamespace(orgID int64) string {
return h.namespacer(orgID) return h.namespacer(orgID)
} }
func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) { func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) {
// create a new context - prevents issues when the request stems from the k8s api itself // create a new context - prevents issues when the request stems from the k8s api itself
// otherwise the context goes through the handlers twice and causes issues // otherwise the context goes through the handlers twice and causes issues
newCtx, cancel, err := h.getK8sContext(ctx) newCtx, cancel, err := h.getK8sContext(ctx)
@ -73,7 +80,7 @@ func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, subresou
return nil, nil return nil, nil
} }
return client.Get(newCtx, name, v1.GetOptions{}, subresource...) return client.Get(newCtx, name, options, subresource...)
} }
func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) {
@ -191,6 +198,28 @@ func (h *k8sHandler) GetStats(ctx context.Context, orgID int64) (*resource.Resou
}) })
} }
// GetUserFromMeta takes what meta accessor gives you from `GetCreatedBy` or `GetUpdatedBy` and returns the user
func (h *k8sHandler) GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) {
parts := strings.Split(userMeta, ":")
if len(parts) < 2 {
return &user.User{}, nil
}
meta := parts[1]
userId, err := strconv.ParseInt(meta, 10, 64)
var u *user.User
if err == nil {
u, err = h.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: userId})
} else {
u, err = h.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: meta})
}
if err != nil && errors.Is(err, user.ErrUserNotFound) {
return &user.User{}, nil
}
return u, err
}
func (h *k8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) { func (h *k8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
cfg := h.restConfigProvider.GetRestConfig(ctx) cfg := h.restConfigProvider.GetRestConfig(ctx)
if cfg == nil { if cfg == nil {

View File

@ -5,6 +5,7 @@ import (
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -21,8 +22,8 @@ func (m *MockK8sHandler) GetNamespace(orgID int64) string {
return args.String(0) return args.String(0)
} }
func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) { func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, orgID, subresource) args := m.Called(ctx, name, orgID, options, subresource)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
} }
@ -79,3 +80,11 @@ func (m *MockK8sHandler) GetStats(ctx context.Context, orgID int64) (*resource.R
} }
return args.Get(0).(*resource.ResourceStatsResponse), args.Error(1) return args.Get(0).(*resource.ResourceStatsResponse), args.Error(1)
} }
func (m *MockK8sHandler) GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) {
args := m.Called(ctx, userMeta)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*user.User), args.Error(1)
}

View File

@ -0,0 +1,34 @@
package client
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/stretchr/testify/require"
)
func TestGetUserFromMeta(t *testing.T) {
userSvcTest := usertest.NewUserServiceFake()
userSvcTest.ExpectedUser = &user.User{
ID: 1,
UID: "uid-value",
}
client := &k8sHandler{
userService: userSvcTest,
}
t.Run("returns user with valid UID", func(t *testing.T) {
result, err := client.GetUserFromMeta(context.Background(), "user:uid-value")
require.NoError(t, err)
require.Equal(t, "uid-value", result.UID)
require.Equal(t, int64(1), result.ID)
})
t.Run("returns user when id is passed in", func(t *testing.T) {
result, err := client.GetUserFromMeta(context.Background(), "user:1")
require.NoError(t, err)
require.Equal(t, "uid-value", result.UID)
require.Equal(t, int64(1), result.ID)
})
}

View File

@ -68,7 +68,6 @@ type DashboardServiceImpl struct {
dashboardStore dashboards.Store dashboardStore dashboards.Store
folderStore folder.FolderStore folderStore folder.FolderStore
folderService folder.Service folderService folder.Service
userService user.Service
orgService org.Service orgService org.Service
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
folderPermissions accesscontrol.FolderPermissionsService folderPermissions accesscontrol.FolderPermissionsService
@ -91,7 +90,7 @@ func ProvideDashboardServiceImpl(
restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient, restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient,
quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper, quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper,
) (*DashboardServiceImpl, error) { ) (*DashboardServiceImpl, error) {
k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore) k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore, userService)
dashSvc := &DashboardServiceImpl{ dashSvc := &DashboardServiceImpl{
cfg: cfg, cfg: cfg,
@ -103,7 +102,6 @@ func ProvideDashboardServiceImpl(
folderStore: folderStore, folderStore: folderStore,
folderService: folderSvc, folderService: folderSvc,
orgService: orgService, orgService: orgService,
userService: userService,
k8sclient: k8sHandler, k8sclient: k8sHandler,
metrics: newDashboardsMetrics(r), metrics: newDashboardsMetrics(r),
dashboardPermissionsReady: make(chan struct{}), dashboardPermissionsReady: make(chan struct{}),
@ -1489,7 +1487,7 @@ func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, quer
query.UID = result.UID query.UID = result.UID
} }
out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, subresource) out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, subresource)
if err != nil && !apierrors.IsNotFound(err) { if err != nil && !apierrors.IsNotFound(err) {
return nil, err return nil, err
} else if err != nil || out == nil { } else if err != nil || out == nil {
@ -1553,7 +1551,7 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) { func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
var out *unstructured.Unstructured var out *unstructured.Unstructured
current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID) current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID, v1.GetOptions{})
if current == nil || err != nil { if current == nil || err != nil {
out, err = dr.k8sclient.Create(ctx, &obj, orgID) out, err = dr.k8sclient.Create(ctx, &obj, orgID)
if err != nil { if err != nil {
@ -1751,7 +1749,7 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
for _, h := range searchResults.Hits { for _, h := range searchResults.Hits {
func(hit v0alpha1.DashboardHit) { func(hit v0alpha1.DashboardHit) {
g.Go(func() error { g.Go(func() error {
out, err := dr.k8sclient.Get(ctx, hit.Name, query.OrgId) out, err := dr.k8sclient.Get(ctx, hit.Name, query.OrgId, v1.GetOptions{})
if err != nil { if err != nil {
return err return err
} else if out == nil { } else if out == nil {
@ -1863,13 +1861,13 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex
out.PluginID = GetPluginIDFromMeta(obj) out.PluginID = GetPluginIDFromMeta(obj)
creator, err := dr.getUserFromMeta(ctx, obj.GetCreatedBy()) creator, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy())
if err != nil { if err != nil {
return nil, err return nil, err
} }
out.CreatedBy = creator.ID out.CreatedBy = creator.ID
updater, err := dr.getUserFromMeta(ctx, obj.GetUpdatedBy()) updater, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetUpdatedBy())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1906,25 +1904,6 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex
return &out, nil return &out, nil
} }
func (dr *DashboardServiceImpl) getUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) {
if userMeta == "" || toUID(userMeta) == "" {
return &user.User{}, nil
}
usr, err := dr.getUser(ctx, toUID(userMeta))
if err != nil && errors.Is(err, user.ErrUserNotFound) {
return &user.User{}, nil
}
return usr, err
}
func (dr *DashboardServiceImpl) getUser(ctx context.Context, uid string) (*user.User, error) {
userId, err := strconv.ParseInt(uid, 10, 64)
if err == nil {
return dr.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: userId})
}
return dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: uid})
}
var pluginIDRepoName = "plugin" var pluginIDRepoName = "plugin"
var fileProvisionedRepoPrefix = "file:" var fileProvisionedRepoPrefix = "file:"
@ -2010,11 +1989,3 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names
return finalObj, nil return finalObj, nil
} }
func toUID(rawIdentifier string) string {
parts := strings.Split(rawIdentifier, ":")
if len(parts) < 2 {
return ""
}
return parts[1]
}

View File

@ -26,7 +26,6 @@ import (
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -315,7 +314,8 @@ func TestGetDashboard(t *testing.T) {
Version: 1, Version: 1,
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
} }
k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once()
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
dashboard, err := service.GetDashboard(ctx, query) dashboard, err := service.GetDashboard(ctx, query)
require.NoError(t, err) require.NoError(t, err)
@ -351,7 +351,8 @@ func TestGetDashboard(t *testing.T) {
Version: 1, Version: 1,
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
} }
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once()
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{ Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{ Columns: []*resource.ResourceTableColumnDefinition{
@ -391,7 +392,7 @@ func TestGetDashboard(t *testing.T) {
t.Run("Should return error when Kubernetes client fails", func(t *testing.T) { t.Run("Should return error when Kubernetes client fails", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once()
dashboard, err := service.GetDashboard(ctx, query) dashboard, err := service.GetDashboard(ctx, query)
require.Error(t, err) require.Error(t, err)
@ -401,7 +402,7 @@ func TestGetDashboard(t *testing.T) {
t.Run("Should return dashboard not found if Kubernetes client returns nil", func(t *testing.T) { t.Run("Should return dashboard not found if Kubernetes client returns nil", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, nil).Once() k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Once()
dashboard, err := service.GetDashboard(ctx, query) dashboard, err := service.GetDashboard(ctx, query)
require.Error(t, err) require.Error(t, err)
require.Equal(t, dashboards.ErrDashboardNotFound, err) require.Equal(t, dashboards.ErrDashboardNotFound, err)
@ -450,6 +451,7 @@ func TestGetAllDashboards(t *testing.T) {
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
} }
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once()
dashes, err := service.GetAllDashboards(ctx) dashes, err := service.GetAllDashboards(ctx)
@ -501,6 +503,7 @@ func TestGetAllDashboardsByOrgId(t *testing.T) {
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
} }
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once()
dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) dashes, err := service.GetAllDashboardsByOrgId(ctx, 1)
@ -534,7 +537,7 @@ func TestGetProvisionedDashboardData(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid", "name": "uid",
"labels": map[string]any{ "labels": map[string]any{
@ -632,7 +635,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid", "name": "uid",
"labels": map[string]any{ "labels": map[string]any{
@ -721,7 +724,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid", "name": "uid",
"labels": map[string]any{ "labels": map[string]any{
@ -812,7 +815,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid", OrgID: 1}).Return(nil).Once() fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid", OrgID: 1}).Return(nil).Once()
fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid3", OrgID: 2}).Return(nil).Once() fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid3", OrgID: 2}).Return(nil).Once()
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil) fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid", "name": "uid",
"annotations": map[string]any{ "annotations": map[string]any{
@ -825,7 +828,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"spec": map[string]any{}, "spec": map[string]any{},
}}, nil).Once() }}, nil).Once()
// should not delete this one, because it does not start with "file:" // should not delete this one, because it does not start with "file:"
k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid2", "name": "uid2",
"annotations": map[string]any{ "annotations": map[string]any{
@ -836,7 +839,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"spec": map[string]any{}, "spec": map[string]any{},
}}, nil).Once() }}, nil).Once()
k8sCliMock.On("Get", mock.Anything, "uid3", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ k8sCliMock.On("Get", mock.Anything, "uid3", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{ "metadata": map[string]any{
"name": "uid3", "name": "uid3",
"annotations": map[string]any{ "annotations": map[string]any{
@ -958,7 +961,7 @@ func TestUnprovisionDashboard(t *testing.T) {
}, },
"spec": map[string]any{}, "spec": map[string]any{},
}} }}
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil)
dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{ dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v0alpha1", "apiVersion": "dashboard.grafana.app/v0alpha1",
"kind": "Dashboard", "kind": "Dashboard",
@ -975,6 +978,7 @@ func TestUnprovisionDashboard(t *testing.T) {
// should update it to be without annotations // should update it to be without annotations
k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, mock.Anything).Return(dashWithoutAnnotations, nil) k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, mock.Anything).Return(dashWithoutAnnotations, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{ Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{ Columns: []*resource.ResourceTableColumnDefinition{
@ -1039,7 +1043,8 @@ func TestGetDashboardsByPluginID(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(uidUnstructured, nil) k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" && return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" &&
req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing" req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing"
@ -1127,7 +1132,8 @@ func TestSaveProvisionedDashboard(t *testing.T) {
t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil) fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
@ -1188,7 +1194,8 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
@ -1199,7 +1206,8 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) { t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
@ -1210,7 +1218,7 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should return an error if uid is invalid", func(t *testing.T) { t.Run("Should return an error if uid is invalid", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
@ -1495,8 +1503,9 @@ func TestGetDashboards(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service) ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything).Return(uid1Unstructured, nil) k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything, mock.Anything).Return(uid1Unstructured, nil)
k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything).Return(uid2Unstructured, nil) k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(uid2Unstructured, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{ Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{ Columns: []*resource.ResourceTableColumnDefinition{
@ -1611,10 +1620,10 @@ func TestGetDashboardUIDByID(t *testing.T) {
} }
func TestUnstructuredToLegacyDashboard(t *testing.T) { func TestUnstructuredToLegacyDashboard(t *testing.T) {
fake := usertest.NewUserServiceFake() k8sCliMock := new(client.MockK8sHandler)
fake.ExpectedUser = &user.User{ID: 10, UID: "useruid"} k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{ID: 10, UID: "useruid"}, nil)
dr := &DashboardServiceImpl{ dr := &DashboardServiceImpl{
userService: fake, k8sclient: k8sCliMock,
} }
t.Run("successfully converts unstructured to legacy dashboard", func(t *testing.T) { t.Run("successfully converts unstructured to legacy dashboard", func(t *testing.T) {
uid := "36b7c825-79cc-435e-acf6-c78bd96a4510" uid := "36b7c825-79cc-435e-acf6-c78bd96a4510"
@ -1912,17 +1921,3 @@ func TestLegacySaveCommandToUnstructured(t *testing.T) {
assert.Equal(t, result.GetAnnotations(), map[string]string(nil)) assert.Equal(t, result.GetAnnotations(), map[string]string(nil))
}) })
} }
func TestToUID(t *testing.T) {
t.Run("parses valid UID", func(t *testing.T) {
rawIdentifier := "user:uid-value"
result := toUID(rawIdentifier)
assert.Equal(t, "uid-value", result)
})
t.Run("returns empty string for invalid identifier", func(t *testing.T) {
rawIdentifier := "invalid-uid"
result := toUID(rawIdentifier)
assert.Equal(t, "", result)
})
}

View File

@ -7,5 +7,5 @@ import (
type Service interface { type Service interface {
Get(context.Context, *GetDashboardVersionQuery) (*DashboardVersionDTO, error) Get(context.Context, *GetDashboardVersionQuery) (*DashboardVersionDTO, error)
DeleteExpired(context.Context, *DeleteExpiredVersionsCommand) error DeleteExpired(context.Context, *DeleteExpiredVersionsCommand) error
List(context.Context, *ListDashboardVersionsQuery) ([]*DashboardVersionDTO, error) List(context.Context, *ListDashboardVersionsQuery) (*DashboardVersionResponse, error)
} }

View File

@ -3,12 +3,26 @@ package dashverimpl
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion" dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
) )
const ( const (
@ -20,16 +34,29 @@ type Service struct {
cfg *setting.Cfg cfg *setting.Cfg
store store store store
dashSvc dashboards.DashboardService dashSvc dashboards.DashboardService
k8sclient client.K8sHandler
features featuremgmt.FeatureToggles
log log.Logger log log.Logger
} }
func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService) dashver.Service { func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, features featuremgmt.FeatureToggles,
restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient) dashver.Service {
return &Service{ return &Service{
cfg: cfg, cfg: cfg,
store: &sqlStore{ store: &sqlStore{
db: db, db: db,
dialect: db.GetDialect(), dialect: db.GetDialect(),
}, },
features: features,
k8sclient: client.NewK8sHandler(
cfg,
request.GetNamespaceMapper(cfg),
v0alpha1.DashboardResourceInfo.GroupVersionResource(),
restConfigProvider,
unified,
dashboardStore,
userService,
),
dashSvc: dashboardService, dashSvc: dashboardService,
log: log.New("dashboard-version"), log: log.New("dashboard-version"),
} }
@ -49,13 +76,21 @@ func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQue
// versions table, at time of this writing), so get the DashboardID if it // versions table, at time of this writing), so get the DashboardID if it
// was not populated. // was not populated.
if query.DashboardID == 0 { if query.DashboardID == 0 {
id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID) id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
query.DashboardID = id query.DashboardID = id
} }
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version)
if err != nil {
return nil, err
}
return version, nil
}
version, err := s.store.Get(ctx, query) version, err := s.store.Get(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, err
@ -95,7 +130,7 @@ func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredV
} }
// List all dashboard versions for the given dashboard ID. // List all dashboard versions for the given dashboard ID.
func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersionDTO, error) { func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) {
// Get the DashboardUID if not populated // Get the DashboardUID if not populated
if query.DashboardUID == "" { if query.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID) u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
@ -109,7 +144,7 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions
// versions table, at time of this writing), so get the DashboardID if it // versions table, at time of this writing), so get the DashboardID if it
// was not populated. // was not populated.
if query.DashboardID == 0 { if query.DashboardID == 0 {
id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID) id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -118,6 +153,21 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions
if query.Limit == 0 { if query.Limit == 0 {
query.Limit = 1000 query.Limit = 1000
} }
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
versions, err := s.listHistoryThroughK8s(
ctx,
query.OrgID,
query.DashboardUID,
int64(query.Limit),
query.ContinueToken,
)
if err != nil {
return nil, err
}
return versions, nil
}
dvs, err := s.store.List(ctx, query) dvs, err := s.store.List(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, err
@ -126,7 +176,9 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions
for i, v := range dvs { for i, v := range dvs {
dtos[i] = v.ToDTO(query.DashboardUID) dtos[i] = v.ToDTO(query.DashboardUID)
} }
return dtos, nil return &dashver.DashboardVersionResponse{
Versions: dtos,
}, nil
} }
// getDashUIDMaybeEmpty is a helper function which takes a dashboardID and // getDashUIDMaybeEmpty is a helper function which takes a dashboardID and
@ -149,8 +201,8 @@ func (s *Service) getDashUIDMaybeEmpty(ctx context.Context, id int64) (string, e
// getDashIDMaybeEmpty is a helper function which takes a dashboardUID and // getDashIDMaybeEmpty is a helper function which takes a dashboardUID and
// returns the ID. If the dashboard is not found, it will return -1. // returns the ID. If the dashboard is not found, it will return -1.
func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string) (int64, error) { func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int64) (int64, error) {
q := dashboards.GetDashboardQuery{UID: uid} q := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID}
result, err := s.dashSvc.GetDashboard(ctx, &q) result, err := s.dashSvc.GetDashboard(ctx, &q)
if err != nil { if err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) { if errors.Is(err, dashboards.ErrDashboardNotFound) {
@ -163,3 +215,116 @@ func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string) (int64, e
} }
return result.ID, nil return result.ID, nil
} }
func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, rv int64) (*dashver.DashboardVersionDTO, error) {
out, err := s.k8sclient.Get(ctx, dashboardUID, orgID, v1.GetOptions{ResourceVersion: strconv.FormatInt(rv, 10)})
if err != nil {
return nil, err
} else if out == nil {
return nil, dashboards.ErrDashboardNotFound
}
dash, err := s.UnstructuredToLegacyDashboardVersion(ctx, out, orgID)
if err != nil {
return nil, err
}
return dash, nil
}
func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string) (*dashver.DashboardVersionResponse, error) {
out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
LabelSelector: utils.LabelKeyGetHistory + "=" + dashboardUID,
Limit: limit,
Continue: continueToken,
})
if err != nil {
return nil, err
} else if out == nil {
return nil, dashboards.ErrDashboardNotFound
}
dashboards := make([]*dashver.DashboardVersionDTO, len(out.Items))
for i, item := range out.Items {
dash, err := s.UnstructuredToLegacyDashboardVersion(ctx, &item, orgID)
if err != nil {
return nil, err
}
dashboards[i] = dash
}
return &dashver.DashboardVersionResponse{
ContinueToken: out.GetContinue(),
Versions: dashboards,
}, nil
}
func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashver.DashboardVersionDTO, error) {
spec, ok := item.Object["spec"].(map[string]any)
if !ok {
return nil, errors.New("error parsing dashboard from k8s response")
}
obj, err := utils.MetaAccessor(item)
if err != nil {
return nil, err
}
uid := obj.GetName()
spec["uid"] = uid
dashVersion := 0
parentVersion := 0
if version, ok := spec["version"].(int64); ok {
dashVersion = int(version)
parentVersion = dashVersion - 1
}
createdBy, err := s.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy())
if err != nil {
return nil, err
}
id, err := obj.GetResourceVersionInt64()
if err != nil {
return nil, err
}
restoreVer, err := getRestoreVersion(obj.GetMessage())
if err != nil {
return nil, err
}
out := dashver.DashboardVersionDTO{
ID: id,
DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
DashboardUID: uid,
Created: obj.GetCreationTimestamp().Time,
CreatedBy: createdBy.ID,
Message: obj.GetMessage(),
RestoredFrom: restoreVer,
Version: dashVersion,
ParentVersion: parentVersion,
Data: simplejson.NewFromAny(spec),
}
return &out, nil
}
var restoreMsg = "Restored from version "
func DashboardRestoreMessage(version int) string {
return fmt.Sprintf("%s%d", restoreMsg, version)
}
func getRestoreVersion(msg string) (int, error) {
parts := strings.Split(msg, restoreMsg)
if len(parts) < 2 {
return 0, nil
}
ver, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, err
}
return int(ver), nil
}

View File

@ -7,18 +7,24 @@ import (
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion" dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
func TestDashboardVersionService(t *testing.T) { func TestDashboardVersionService(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
t.Run("Get dashboard version", func(t *testing.T) { t.Run("Get dashboard version", func(t *testing.T) {
dashboard := &dashver.DashboardVersion{ dashboard := &dashver.DashboardVersion{
@ -32,6 +38,44 @@ func TestDashboardVersionService(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, dashboard.ToDTO("uid"), dashboardVersion) require.Equal(t, dashboard.ToDTO("uid"), dashboardVersion)
}) })
t.Run("Get dashboard version through k8s", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
mockCli.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(&unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{
"version": int64(10),
},
}}, nil).Once()
res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
Version: 10,
})
require.Nil(t, err)
require.Equal(t, res, &dashver.DashboardVersionDTO{
ID: 12, // RV should be used
Version: 10,
ParentVersion: 9,
DashboardID: 42,
DashboardUID: "uid",
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(10)}),
})
})
} }
func TestDeleteExpiredVersions(t *testing.T) { func TestDeleteExpiredVersions(t *testing.T) {
@ -42,7 +86,7 @@ func TestDeleteExpiredVersions(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{ dashboardVersionService := Service{
cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService} cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
t.Run("Don't delete anything if there are no expired versions", func(t *testing.T) { t.Run("Don't delete anything if there are no expired versions", func(t *testing.T) {
err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4}) err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4})
@ -67,7 +111,7 @@ func TestListDashboardVersions(t *testing.T) {
t.Run("List all versions for a given Dashboard ID", func(t *testing.T) { t.Run("List all versions for a given Dashboard ID", func(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{
{ID: 1, DashboardID: 42}, {ID: 1, DashboardID: 42},
} }
@ -78,15 +122,15 @@ func TestListDashboardVersions(t *testing.T) {
query := dashver.ListDashboardVersionsQuery{DashboardID: 42} query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
res, err := dashboardVersionService.List(context.Background(), &query) res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(res)) require.Equal(t, 1, len(res.Versions))
// validate that the UID was populated // validate that the UID was populated
require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res)
}) })
t.Run("List all versions for a non-existent DashboardID", func(t *testing.T) { t.Run("List all versions for a non-existent DashboardID", func(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()}
dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{
{ID: 1, DashboardID: 42}, {ID: 1, DashboardID: 42},
} }
@ -96,15 +140,15 @@ func TestListDashboardVersions(t *testing.T) {
query := dashver.ListDashboardVersionsQuery{DashboardID: 42} query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
res, err := dashboardVersionService.List(context.Background(), &query) res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(res)) require.Equal(t, 1, len(res.Versions))
// The DashboardID remains populated with the given value, even though the dash was not found // The DashboardID remains populated with the given value, even though the dash was not found
require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42}}, res) require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42}}}, res)
}) })
t.Run("List all versions for a given DashboardUID", func(t *testing.T) { t.Run("List all versions for a given DashboardUID", func(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()}
dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}}
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).
Return(&dashboards.Dashboard{ID: 42}, nil) Return(&dashboards.Dashboard{ID: 42}, nil)
@ -112,15 +156,15 @@ func TestListDashboardVersions(t *testing.T) {
query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"}
res, err := dashboardVersionService.List(context.Background(), &query) res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(res)) require.Equal(t, 1, len(res.Versions))
// validate that the dashboardID was populated from the GetDashboard method call. // validate that the dashboardID was populated from the GetDashboard method call.
require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res)
}) })
t.Run("List all versions for a given non-existent DashboardUID", func(t *testing.T) { t.Run("List all versions for a given non-existent DashboardUID", func(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()}
dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}}
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).
Return(nil, dashboards.ErrDashboardNotFound) Return(nil, dashboards.ErrDashboardNotFound)
@ -128,15 +172,15 @@ func TestListDashboardVersions(t *testing.T) {
query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"}
res, err := dashboardVersionService.List(context.Background(), &query) res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(res)) require.Equal(t, 1, len(res.Versions))
// validate that the dashboardUID & ID are populated, even though the dash was not found // validate that the dashboardUID & ID are populated, even though the dash was not found
require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res)
}) })
t.Run("List Dashboard versions - error from store", func(t *testing.T) { t.Run("List Dashboard versions - error from store", func(t *testing.T) {
dashboardVersionStore := newDashboardVersionStoreFake() dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t) dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()}
dashboardVersionStore.ExpectedError = dashver.ErrDashboardVersionNotFound dashboardVersionStore.ExpectedError = dashver.ErrDashboardVersionNotFound
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, DashboardUID: "42"} query := dashver.ListDashboardVersionsQuery{DashboardID: 42, DashboardUID: "42"}
@ -144,6 +188,46 @@ func TestListDashboardVersions(t *testing.T) {
require.Nil(t, res) require.Nil(t, res)
require.ErrorIs(t, err, dashver.ErrDashboardVersionNotFound) require.ErrorIs(t, err, dashver.ErrDashboardVersionNotFound)
}) })
t.Run("List all versions for a given Dashboard ID through k8s", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
mockCli.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{
"version": int64(5),
},
}}}}, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 1, len(res.Versions))
require.EqualValues(t, &dashver.DashboardVersionResponse{
Versions: []*dashver.DashboardVersionDTO{{
ID: 12, // should take rv
DashboardID: 42,
ParentVersion: 4,
Version: 5, // should take from spec
DashboardUID: "uid",
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(5)}),
}}}, res)
})
} }
type FakeDashboardVersionStore struct { type FakeDashboardVersionStore struct {

View File

@ -34,7 +34,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) {
query := dashver.GetDashboardVersionQuery{ query := dashver.GetDashboardVersionQuery{
DashboardID: savedDash.ID, DashboardID: savedDash.ID,
Version: savedDash.Version, Version: int64(savedDash.Version),
OrgID: 1, OrgID: 1,
} }
@ -60,7 +60,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) {
t.Run("Attempt to get a version that doesn't exist", func(t *testing.T) { t.Run("Attempt to get a version that doesn't exist", func(t *testing.T) {
query := dashver.GetDashboardVersionQuery{ query := dashver.GetDashboardVersionQuery{
DashboardID: int64(999), DashboardID: int64(999),
Version: 123, Version: int64(123),
OrgID: 1, OrgID: 1,
} }

View File

@ -10,6 +10,7 @@ type FakeDashboardVersionService struct {
ExpectedDashboardVersion *dashver.DashboardVersionDTO ExpectedDashboardVersion *dashver.DashboardVersionDTO
ExpectedDashboardVersions []*dashver.DashboardVersionDTO ExpectedDashboardVersions []*dashver.DashboardVersionDTO
ExpectedListDashboarVersions []*dashver.DashboardVersionDTO ExpectedListDashboarVersions []*dashver.DashboardVersionDTO
ExpectedContinueToken string
counter int counter int
ExpectedError error ExpectedError error
} }
@ -30,6 +31,9 @@ func (f *FakeDashboardVersionService) DeleteExpired(ctx context.Context, cmd *da
return f.ExpectedError return f.ExpectedError
} }
func (f *FakeDashboardVersionService) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersionDTO, error) { func (f *FakeDashboardVersionService) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) {
return f.ExpectedListDashboarVersions, f.ExpectedError return &dashver.DashboardVersionResponse{
ContinueToken: f.ExpectedContinueToken,
Versions: f.ExpectedListDashboarVersions,
}, f.ExpectedError
} }

View File

@ -52,7 +52,7 @@ type GetDashboardVersionQuery struct {
DashboardID int64 DashboardID int64
DashboardUID string DashboardUID string
OrgID int64 OrgID int64
Version int Version int64
} }
type DeleteExpiredVersionsCommand struct { type DeleteExpiredVersionsCommand struct {
@ -65,7 +65,14 @@ type ListDashboardVersionsQuery struct {
OrgID int64 OrgID int64
Limit int Limit int
Start int Start int
ContinueToken string
} }
type DashboardVersionResponse struct {
ContinueToken string `json:"continueToken"`
Versions []*DashboardVersionDTO `json:"versions"`
}
type DashboardVersionDTO struct { type DashboardVersionDTO struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DashboardID int64 `json:"dashboardId"` DashboardID int64 `json:"dashboardId"`
@ -94,3 +101,8 @@ type DashboardVersionMeta struct {
Data *simplejson.Json `json:"data"` Data *simplejson.Json `json:"data"`
CreatedBy string `json:"createdBy"` CreatedBy string `json:"createdBy"`
} }
type DashboardVersionResponseMeta struct {
ContinueToken string `json:"continueToken"`
Versions []DashboardVersionMeta `json:"versions"`
}

View File

@ -321,6 +321,11 @@ func (c *cdkListIterator) ContinueToken() string {
return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey) return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey)
} }
// ContinueTokenWithCurrentRV implements ListIterator.
func (c *cdkListIterator) ContinueTokenWithCurrentRV() string {
return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey)
}
// Name implements ListIterator. // Name implements ListIterator.
func (c *cdkListIterator) Name() string { func (c *cdkListIterator) Name() string {
return c.currentKey // TODO (parse name from key) return c.currentKey // TODO (parse name from key)

View File

@ -41,6 +41,9 @@ type ListIterator interface {
// The token that can be used to start iterating *after* this item // The token that can be used to start iterating *after* this item
ContinueToken() string ContinueToken() string
// The token that can be used to start iterating *before* this item
ContinueTokenWithCurrentRV() string
// ResourceVersion of the current item // ResourceVersion of the current item
ResourceVersion() int64 ResourceVersion() int64
@ -756,9 +759,16 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
rsp.Items = append(rsp.Items, item) rsp.Items = append(rsp.Items, item)
if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes { if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes {
t := iter.ContinueToken() t := iter.ContinueToken()
if req.Source == ListRequest_HISTORY {
// history lists in desc order, so the continue token takes the
// final RV in the list, and then will start from there in the next page,
// rather than the lists first RV
t = iter.ContinueTokenWithCurrentRV()
}
if iter.Next() { if iter.Next() {
rsp.NextPageToken = t rsp.NextPageToken = t
} }
break break
} }
} }

View File

@ -519,6 +519,10 @@ func (l *listIter) ContinueToken() string {
return ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String() return ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String()
} }
func (l *listIter) ContinueTokenWithCurrentRV() string {
return ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset}.String()
}
func (l *listIter) Error() error { func (l *listIter) Error() error {
return l.err return l.err
} }

View File

@ -16,6 +16,6 @@ WHERE 1 = 1
AND {{ .Ident "action" }} = 3 AND {{ .Ident "action" }} = 3
{{ end }} {{ end }}
{{ if (gt .StartRV 0) }} {{ if (gt .StartRV 0) }}
AND {{ .Ident "resource_version" }} > {{ .Arg .StartRV }} AND {{ .Ident "resource_version" }} < {{ .Arg .StartRV }}
{{ end }} {{ end }}
ORDER BY resource_version DESC ORDER BY resource_version DESC

View File

@ -351,6 +351,83 @@ func TestIntegrationBackendList(t *testing.T) {
require.Equal(t, rv8, continueToken.ResourceVersion) require.Equal(t, rv8, continueToken.ResourceVersion)
require.Equal(t, int64(4), continueToken.StartOffset) require.Equal(t, int64(4), continueToken.StartOffset)
}) })
// add 5 events for item1 - should be saved to history
rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
require.NoError(t, err)
require.Greater(t, rvHistory1, rv1)
rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
require.NoError(t, err)
require.Greater(t, rvHistory2, rvHistory1)
rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
require.NoError(t, err)
require.Greater(t, rvHistory3, rvHistory2)
rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
require.NoError(t, err)
require.Greater(t, rvHistory4, rvHistory3)
rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
require.NoError(t, err)
require.Greater(t, rvHistory5, rvHistory4)
t.Run("fetch first history page at revision with limit", func(t *testing.T) {
res, err := server.List(ctx, &resource.ListRequest{
Limit: 3,
Source: resource.ListRequest_HISTORY,
Options: &resource.ListOptions{
Key: &resource.ResourceKey{
Namespace: "namespace",
Group: "group",
Resource: "resource",
Name: "item1",
},
},
})
require.NoError(t, err)
require.NoError(t, err)
require.Nil(t, res.Error)
require.Len(t, res.Items, 3)
t.Log(res.Items)
// should be in desc order, so the newest RVs are returned first
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
require.Equal(t, rvHistory5, res.Items[0].ResourceVersion)
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
require.Equal(t, rvHistory4, res.Items[1].ResourceVersion)
require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value))
require.Equal(t, rvHistory3, res.Items[2].ResourceVersion)
continueToken, err := sql.GetContinueToken(res.NextPageToken)
require.NoError(t, err)
// should return the furthest back RV as the next page token
require.Equal(t, rvHistory3, continueToken.ResourceVersion)
})
t.Run("fetch second page of history at revision", func(t *testing.T) {
continueToken := &sql.ContinueToken{
ResourceVersion: rvHistory3,
StartOffset: 2,
}
res, err := server.List(ctx, &resource.ListRequest{
NextPageToken: continueToken.String(),
Limit: 2,
Source: resource.ListRequest_HISTORY,
Options: &resource.ListOptions{
Key: &resource.ResourceKey{
Namespace: "namespace",
Group: "group",
Resource: "resource",
Name: "item1",
},
},
})
require.NoError(t, err)
require.Nil(t, res.Error)
require.Len(t, res.Items, 2)
t.Log(res.Items)
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
require.Equal(t, rvHistory2, res.Items[0].ResourceVersion)
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
require.Equal(t, rvHistory1, res.Items[1].ResourceVersion)
})
} }
func TestIntegrationBlobSupport(t *testing.T) { func TestIntegrationBlobSupport(t *testing.T) {

View File

@ -10,5 +10,5 @@ WHERE 1 = 1
AND `group` = 'gg' AND `group` = 'gg'
AND `resource` = 'rr' AND `resource` = 'rr'
AND `action` = 3 AND `action` = 3
AND `resource_version` > 123456 AND `resource_version` < 123456
ORDER BY resource_version DESC ORDER BY resource_version DESC

View File

@ -10,5 +10,5 @@ WHERE 1 = 1
AND "group" = 'gg' AND "group" = 'gg'
AND "resource" = 'rr' AND "resource" = 'rr'
AND "action" = 3 AND "action" = 3
AND "resource_version" > 123456 AND "resource_version" < 123456
ORDER BY resource_version DESC ORDER BY resource_version DESC

View File

@ -10,5 +10,5 @@ WHERE 1 = 1
AND "group" = 'gg' AND "group" = 'gg'
AND "resource" = 'rr' AND "resource" = 'rr'
AND "action" = 3 AND "action" = 3
AND "resource_version" > 123456 AND "resource_version" < 123456
ORDER BY resource_version DESC ORDER BY resource_version DESC

View File

@ -374,7 +374,13 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
} }
public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => { public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); let versionRsp;
if (config.featureToggles.kubernetesCliDashboards) {
// the id here is the resource version in k8s, use this instead to get the specific version
versionRsp = await historySrv.restoreDashboard(version.uid, version.id);
} else {
versionRsp = await historySrv.restoreDashboard(version.uid, version.version);
}
if (!Number.isInteger(versionRsp.version)) { if (!Number.isInteger(versionRsp.version)) {
return false; return false;

View File

@ -112,7 +112,9 @@ describe('VersionsEditView', () => {
}); });
function getVersions() { function getVersions() {
return [ return {
continueToken: '',
versions: [
{ {
id: 4, id: 4,
dashboardId: 1, dashboardId: 1,
@ -149,7 +151,8 @@ function getVersions() {
message: '', message: '',
checked: false, checked: false,
}, },
]; ],
};
} }
async function buildTestScene() { async function buildTestScene() {

View File

@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import { Spinner, Stack } from '@grafana/ui'; import { Spinner, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
@ -41,6 +42,7 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
public static Component = VersionsEditorSettingsListView; public static Component = VersionsEditorSettingsListView;
private _limit: number = VERSIONS_FETCH_LIMIT; private _limit: number = VERSIONS_FETCH_LIMIT;
private _start = 0; private _start = 0;
private _continueToken = '';
constructor(state: VersionsEditViewState) { constructor(state: VersionsEditViewState) {
super({ super({
@ -102,14 +104,20 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
this.setState({ isAppending: append }); this.setState({ isAppending: append });
const requestOptions = this._continueToken
? { limit: this._limit, start: this._start, continueToken: this._continueToken }
: { limit: this._limit, start: this._start };
historySrv historySrv
.getHistoryList(uid, { limit: this._limit, start: this._start }) .getHistoryList(uid, requestOptions)
.then((result) => { .then((result) => {
this.setState({ this.setState({
isLoading: false, isLoading: false,
versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)], versions: [...(this.state.versions ?? []), ...this.decorateVersions(result.versions)],
}); });
this._start += this._limit; this._start += this._limit;
// Update the continueToken for the next request, if available
this._continueToken = result.continueToken ?? '';
}) })
.catch((err) => console.log(err)) .catch((err) => console.log(err))
.finally(() => this.setState({ isAppending: false })); .finally(() => this.setState({ isAppending: false }));
@ -127,9 +135,15 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
if (!this._dashboard.state.uid) { if (!this._dashboard.state.uid) {
return; return;
} }
let lhs, rhs;
const lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); if (config.featureToggles.kubernetesCliDashboards) {
const rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); // the id here is the resource version in k8s, use this instead to get the specific version
lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.id);
rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.id);
} else {
lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version);
rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version);
}
this.setState({ this.setState({
baseInfo, baseInfo,
@ -145,6 +159,7 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
}; };
public reset = () => { public reset = () => {
this._continueToken = '';
this.setState({ this.setState({
baseInfo: undefined, baseInfo: undefined,
diffData: { diffData: {

View File

@ -62,11 +62,11 @@ describe('historySrv', () => {
describe('getDashboardVersion', () => { describe('getDashboardVersion', () => {
it('should return a version object for the given dashboard id and version', () => { it('should return a version object for the given dashboard id and version', () => {
getMock.mockImplementation(() => Promise.resolve(versionsResponse[0])); getMock.mockImplementation(() => Promise.resolve(versionsResponse.versions[0]));
historySrv = new HistorySrv(); historySrv = new HistorySrv();
return historySrv.getDashboardVersion(dash.uid, 4).then((version) => { return historySrv.getDashboardVersion(dash.uid, 4).then((version) => {
expect(version).toEqual(versionsResponse[0]); expect(version).toEqual(versionsResponse.versions[0]);
}); });
}); });

View File

@ -4,6 +4,7 @@ import { Dashboard } from '@grafana/schema';
export interface HistoryListOpts { export interface HistoryListOpts {
limit: number; limit: number;
start: number; start: number;
continueToken?: string;
} }
export interface RevisionsModel { export interface RevisionsModel {

View File

@ -1,5 +1,7 @@
export function versions() { export function versions() {
return [ return {
continueToken: '',
versions: [
{ {
id: 4, id: 4,
dashboardId: 1, dashboardId: 1,
@ -45,7 +47,8 @@ export function versions() {
createdBy: 'admin', createdBy: 'admin',
message: '', message: '',
}, },
]; ],
};
} }
export function restore(version: number, restoredFrom?: number) { export function restore(version: number, restoredFrom?: number) {

View File

@ -66,7 +66,7 @@ describe('VersionSettings', () => {
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row');
expect(tableBodyRows.length).toBe(versions.length); expect(tableBodyRows.length).toBe(versions.versions.length);
const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0]; const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0];
@ -76,7 +76,11 @@ describe('VersionSettings', () => {
test('does not render buttons if versions === 1', async () => { test('does not render buttons if versions === 1', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); historySrv.getHistoryList.mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, 1),
});
setup(); setup();
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
@ -90,7 +94,11 @@ describe('VersionSettings', () => {
test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => { test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); historySrv.getHistoryList.mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 5),
});
setup(); setup();
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
@ -104,7 +112,11 @@ describe('VersionSettings', () => {
test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); historySrv.getHistoryList.mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT),
});
setup(); setup();
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
@ -124,9 +136,17 @@ describe('VersionSettings', () => {
test('clicking show more appends results to the table', async () => { test('clicking show more appends results to the table', async () => {
historySrv.getHistoryList historySrv.getHistoryList
// @ts-ignore // @ts-ignore
.mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) .mockImplementationOnce(() =>
.mockImplementationOnce( Promise.resolve({
() => new Promise((resolve) => setTimeout(() => resolve(versions.slice(VERSIONS_FETCH_LIMIT)), 1000)) continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT),
})
)
.mockImplementationOnce(() =>
Promise.resolve({
continueToken: versions.continueToken,
versions: versions.versions.slice(VERSIONS_FETCH_LIMIT),
})
); );
setup(); setup();
@ -146,13 +166,16 @@ describe('VersionSettings', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Fetching more entries/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Fetching more entries/i)).not.toBeInTheDocument();
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.length); expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.versions.length);
}); });
}); });
test('selecting two versions and clicking compare button should render compare view', async () => { test('selecting two versions and clicking compare button should render compare view', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); historySrv.getHistoryList.mockResolvedValue({
continueToken: versions.continueToken,
versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT),
});
historySrv.getDashboardVersion historySrv.getDashboardVersion
// @ts-ignore // @ts-ignore
.mockImplementationOnce(() => Promise.resolve(diffs.lhs)) .mockImplementationOnce(() => Promise.resolve(diffs.lhs))

View File

@ -1,6 +1,7 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import * as React from 'react'; import * as React from 'react';
import { config } from '@grafana/runtime';
import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Spinner, HorizontalGroup } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { import {
@ -38,11 +39,13 @@ export const VERSIONS_FETCH_LIMIT = 10;
export class VersionsSettings extends PureComponent<Props, State> { export class VersionsSettings extends PureComponent<Props, State> {
limit: number; limit: number;
start: number; start: number;
continueToken: string;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.limit = VERSIONS_FETCH_LIMIT; this.limit = VERSIONS_FETCH_LIMIT;
this.start = 0; this.start = 0;
this.continueToken = '';
this.state = { this.state = {
isAppending: true, isAppending: true,
isLoading: true, isLoading: true,
@ -62,14 +65,20 @@ export class VersionsSettings extends PureComponent<Props, State> {
getVersions = (append = false) => { getVersions = (append = false) => {
this.setState({ isAppending: append }); this.setState({ isAppending: append });
const requestOptions = this.continueToken
? { limit: this.limit, start: this.start, continueToken: this.continueToken }
: { limit: this.limit, start: this.start };
historySrv historySrv
.getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) .getHistoryList(this.props.dashboard.uid, requestOptions)
.then((res) => { .then((res) => {
this.setState({ this.setState({
isLoading: false, isLoading: false,
versions: [...this.state.versions, ...this.decorateVersions(res)], versions: [...(this.state.versions ?? []), ...this.decorateVersions(res.versions)],
}); });
this.start += this.limit; this.start += this.limit;
// Update the continueToken for the next request, if available
this.continueToken = res.continueToken ?? '';
}) })
.catch((err) => console.log(err)) .catch((err) => console.log(err))
.finally(() => this.setState({ isAppending: false })); .finally(() => this.setState({ isAppending: false }));
@ -84,8 +93,15 @@ export class VersionsSettings extends PureComponent<Props, State> {
isLoading: true, isLoading: true,
}); });
const lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version); let lhs, rhs;
const rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version); if (config.featureToggles.kubernetesCliDashboards) {
// the id here is the resource version in k8s, use this instead to get the specific version
lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.id);
rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.id);
} else {
lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version);
rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version);
}
this.setState({ this.setState({
baseInfo, baseInfo,
@ -121,6 +137,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
}; };
reset = () => { reset = () => {
this.continueToken = '';
this.setState({ this.setState({
baseInfo: undefined, baseInfo: undefined,
diffData: { diffData: {

View File

@ -1,4 +1,6 @@
export const versions = [ export const versions = {
continueToken: '',
versions: [
{ {
id: 249, id: 249,
dashboardId: 74, dashboardId: 74,
@ -120,7 +122,8 @@ export const versions = [
createdBy: 'admin', createdBy: 'admin',
message: '', message: '',
}, },
]; ],
};
export const diffs = { export const diffs = {
lhs: { lhs: {

View File

@ -5,12 +5,13 @@ import { ConfirmModal } from '@grafana/ui';
import { useDashboardRestore } from './useDashboardRestore'; import { useDashboardRestore } from './useDashboardRestore';
export interface RevertDashboardModalProps { export interface RevertDashboardModalProps {
hideModal: () => void; hideModal: () => void;
id: number;
version: number; version: number;
} }
export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => { export const RevertDashboardModal = ({ hideModal, id, version }: RevertDashboardModalProps) => {
// TODO: how should state.error be handled? // TODO: how should state.error be handled?
const { state, onRestoreDashboard } = useDashboardRestore(version); const { state, onRestoreDashboard } = useDashboardRestore(id, version);
useEffect(() => { useEffect(() => {
if (!state.loading && state.value) { if (!state.loading && state.value) {

View File

@ -43,6 +43,7 @@ export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLat
icon="history" icon="history"
onClick={() => { onClick={() => {
showModal(RevertDashboardModal, { showModal(RevertDashboardModal, {
id: baseInfo.id,
version: baseInfo.version, version: baseInfo.version,
hideModal, hideModal,
}); });

View File

@ -60,6 +60,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsT
icon="history" icon="history"
onClick={() => { onClick={() => {
showModal(RevertDashboardModal, { showModal(RevertDashboardModal, {
id: version.id,
version: version.version, version: version.version,
hideModal, hideModal,
}); });

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useAsyncFn } from 'react-use'; import { useAsyncFn } from 'react-use';
import { locationUtil } from '@grafana/data'; import { locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { historySrv } from 'app/features/dashboard-scene/settings/version-history'; import { historySrv } from 'app/features/dashboard-scene/settings/version-history';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
@ -16,9 +16,12 @@ const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
return await historySrv.restoreDashboard(dashboard.uid, version); return await historySrv.restoreDashboard(dashboard.uid, version);
}; };
export const useDashboardRestore = (version: number) => { export const useDashboardRestore = (id: number, version: number) => {
const dashboard = useSelector((state) => state.dashboard.getModel()); const dashboard = useSelector((state) => state.dashboard.getModel());
const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); const [state, onRestoreDashboard] = useAsyncFn(
async () => await restoreDashboard(config.featureToggles.kubernetesCliDashboards ? id : version, dashboard!),
[]
);
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
useEffect(() => { useEffect(() => {