Merge branch 'main' into ismail/prom/3-remove-series-endpoint
This commit is contained in:
@@ -53,6 +53,7 @@ func (s *Service) Checks() []checks.Check {
|
|||||||
s.pluginStore,
|
s.pluginStore,
|
||||||
s.pluginContextProvider,
|
s.pluginContextProvider,
|
||||||
s.pluginClient,
|
s.pluginClient,
|
||||||
|
s.pluginRepo,
|
||||||
),
|
),
|
||||||
plugincheck.New(
|
plugincheck.New(
|
||||||
s.pluginStore,
|
s.pluginStore,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@@ -20,6 +21,7 @@ const (
|
|||||||
CheckID = "datasource"
|
CheckID = "datasource"
|
||||||
HealthCheckStepID = "health-check"
|
HealthCheckStepID = "health-check"
|
||||||
UIDValidationStepID = "uid-validation"
|
UIDValidationStepID = "uid-validation"
|
||||||
|
MissingPluginStepID = "missing-plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type check struct {
|
type check struct {
|
||||||
@@ -27,6 +29,7 @@ type check struct {
|
|||||||
PluginStore pluginstore.Store
|
PluginStore pluginstore.Store
|
||||||
PluginContextProvider pluginContextProvider
|
PluginContextProvider pluginContextProvider
|
||||||
PluginClient plugins.Client
|
PluginClient plugins.Client
|
||||||
|
PluginRepo repo.Service
|
||||||
log log.Logger
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +38,14 @@ func New(
|
|||||||
pluginStore pluginstore.Store,
|
pluginStore pluginstore.Store,
|
||||||
pluginContextProvider pluginContextProvider,
|
pluginContextProvider pluginContextProvider,
|
||||||
pluginClient plugins.Client,
|
pluginClient plugins.Client,
|
||||||
|
pluginRepo repo.Service,
|
||||||
) checks.Check {
|
) checks.Check {
|
||||||
return &check{
|
return &check{
|
||||||
DatasourceSvc: datasourceSvc,
|
DatasourceSvc: datasourceSvc,
|
||||||
PluginStore: pluginStore,
|
PluginStore: pluginStore,
|
||||||
PluginContextProvider: pluginContextProvider,
|
PluginContextProvider: pluginContextProvider,
|
||||||
PluginClient: pluginClient,
|
PluginClient: pluginClient,
|
||||||
|
PluginRepo: pluginRepo,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +74,11 @@ func (c *check) Steps() []checks.Step {
|
|||||||
PluginClient: c.PluginClient,
|
PluginClient: c.PluginClient,
|
||||||
log: c.log,
|
log: c.log,
|
||||||
},
|
},
|
||||||
|
&missingPluginStep{
|
||||||
|
PluginStore: c.PluginStore,
|
||||||
|
PluginRepo: c.PluginRepo,
|
||||||
|
log: c.log,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,22 +155,8 @@ func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any
|
|||||||
pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
|
pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, plugins.ErrPluginNotRegistered) {
|
if errors.Is(err, plugins.ErrPluginNotRegistered) {
|
||||||
// The plugin is not installed
|
// The plugin is not installed, handle this in the missing plugin step
|
||||||
return checks.NewCheckReportFailure(
|
return nil, nil
|
||||||
advisor.CheckReportFailureSeverityHigh,
|
|
||||||
s.ID(),
|
|
||||||
ds.Name,
|
|
||||||
[]advisor.CheckErrorLink{
|
|
||||||
{
|
|
||||||
Message: "Delete data source",
|
|
||||||
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Message: "Install plugin",
|
|
||||||
Url: fmt.Sprintf("/plugins/%s", ds.Type),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
), nil
|
|
||||||
}
|
}
|
||||||
// Unable to check health check
|
// Unable to check health check
|
||||||
s.log.Error("Failed to get plugin context", "datasource_uid", ds.UID, "error", err)
|
s.log.Error("Failed to get plugin context", "datasource_uid", ds.UID, "error", err)
|
||||||
@@ -196,6 +192,61 @@ func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type missingPluginStep struct {
|
||||||
|
PluginStore pluginstore.Store
|
||||||
|
PluginRepo repo.Service
|
||||||
|
log log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *missingPluginStep) Title() string {
|
||||||
|
return "Missing plugin check"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *missingPluginStep) Description() string {
|
||||||
|
return "Checks if the plugin associated with the data source is installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *missingPluginStep) Resolution() string {
|
||||||
|
return "Delete the datasource or install the plugin."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *missingPluginStep) ID() string {
|
||||||
|
return MissingPluginStepID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *missingPluginStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportFailure, error) {
|
||||||
|
ds, ok := i.(*datasources.DataSource)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid item type %T", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists := s.PluginStore.Plugin(ctx, ds.Type)
|
||||||
|
if !exists {
|
||||||
|
links := []advisor.CheckErrorLink{
|
||||||
|
{
|
||||||
|
Message: "Delete data source",
|
||||||
|
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := s.PluginRepo.PluginInfo(ctx, ds.Type)
|
||||||
|
if err == nil {
|
||||||
|
// Plugin is available in the repo
|
||||||
|
links = append(links, advisor.CheckErrorLink{
|
||||||
|
Message: "Install plugin",
|
||||||
|
Url: fmt.Sprintf("/plugins/%s", ds.Type),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The plugin is not installed
|
||||||
|
return checks.NewCheckReportFailure(
|
||||||
|
advisor.CheckReportFailureSeverityHigh,
|
||||||
|
s.ID(),
|
||||||
|
ds.Name,
|
||||||
|
links,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
type pluginContextProvider interface {
|
type pluginContextProvider interface {
|
||||||
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
|
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package datasourcecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
@@ -9,11 +10,37 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runChecks executes all steps for all items and returns the failures
|
||||||
|
func runChecks(check *check) ([]advisor.CheckReportFailure, error) {
|
||||||
|
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
||||||
|
items, err := check.Items(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
failures := []advisor.CheckReportFailure{}
|
||||||
|
for _, step := range check.Steps() {
|
||||||
|
for _, item := range items {
|
||||||
|
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if stepFailures != nil {
|
||||||
|
failures = append(failures, *stepFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheck_Run(t *testing.T) {
|
func TestCheck_Run(t *testing.T) {
|
||||||
t.Run("should return no failures when all datasources are healthy", func(t *testing.T) {
|
t.Run("should return no failures when all datasources are healthy", func(t *testing.T) {
|
||||||
datasources := []*datasources.DataSource{
|
datasources := []*datasources.DataSource{
|
||||||
@@ -24,30 +51,20 @@ func TestCheck_Run(t *testing.T) {
|
|||||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: true}
|
||||||
|
|
||||||
check := &check{
|
check := &check{
|
||||||
DatasourceSvc: mockDatasourceSvc,
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
PluginContextProvider: mockPluginContextProvider,
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
PluginClient: mockPluginClient,
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
failures, err := runChecks(check)
|
||||||
items, err := check.Items(ctx)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
failures := []advisor.CheckReportFailure{}
|
|
||||||
for _, step := range check.Steps() {
|
|
||||||
for _, item := range items {
|
|
||||||
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if stepFailures != nil {
|
|
||||||
failures = append(failures, *stepFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 2, len(items))
|
|
||||||
assert.Empty(t, failures)
|
assert.Empty(t, failures)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -59,30 +76,20 @@ func TestCheck_Run(t *testing.T) {
|
|||||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: true}
|
||||||
|
|
||||||
check := &check{
|
check := &check{
|
||||||
DatasourceSvc: mockDatasourceSvc,
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
PluginContextProvider: mockPluginContextProvider,
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
PluginClient: mockPluginClient,
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
failures, err := runChecks(check)
|
||||||
items, err := check.Items(ctx)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
failures := []advisor.CheckReportFailure{}
|
|
||||||
for _, step := range check.Steps() {
|
|
||||||
for _, item := range items {
|
|
||||||
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if stepFailures != nil {
|
|
||||||
failures = append(failures, *stepFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, len(items))
|
|
||||||
assert.Len(t, failures, 1)
|
assert.Len(t, failures, 1)
|
||||||
assert.Equal(t, "uid-validation", failures[0].StepID)
|
assert.Equal(t, "uid-validation", failures[0].StepID)
|
||||||
})
|
})
|
||||||
@@ -95,30 +102,20 @@ func TestCheck_Run(t *testing.T) {
|
|||||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError}}
|
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError}}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: true}
|
||||||
|
|
||||||
check := &check{
|
check := &check{
|
||||||
DatasourceSvc: mockDatasourceSvc,
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
PluginContextProvider: mockPluginContextProvider,
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
PluginClient: mockPluginClient,
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
failures, err := runChecks(check)
|
||||||
items, err := check.Items(ctx)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
failures := []advisor.CheckReportFailure{}
|
|
||||||
for _, step := range check.Steps() {
|
|
||||||
for _, item := range items {
|
|
||||||
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if stepFailures != nil {
|
|
||||||
failures = append(failures, *stepFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, len(items))
|
|
||||||
assert.Len(t, failures, 1)
|
assert.Len(t, failures, 1)
|
||||||
assert.Equal(t, "health-check", failures[0].StepID)
|
assert.Equal(t, "health-check", failures[0].StepID)
|
||||||
})
|
})
|
||||||
@@ -130,31 +127,21 @@ func TestCheck_Run(t *testing.T) {
|
|||||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
mockPluginClient := &MockPluginClient{err: plugins.ErrMethodNotImplemented}
|
mockPluginClient := &MockPluginClient{err: plugins.ErrMethodNotImplemented}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: true}
|
||||||
|
|
||||||
check := &check{
|
check := &check{
|
||||||
DatasourceSvc: mockDatasourceSvc,
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
PluginContextProvider: mockPluginContextProvider,
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
PluginClient: mockPluginClient,
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
failures, err := runChecks(check)
|
||||||
items, err := check.Items(ctx)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
failures := []advisor.CheckReportFailure{}
|
assert.Empty(t, failures)
|
||||||
for _, step := range check.Steps() {
|
|
||||||
for _, item := range items {
|
|
||||||
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if stepFailures != nil {
|
|
||||||
failures = append(failures, *stepFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, len(items))
|
|
||||||
assert.Len(t, failures, 0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should return failure when plugin is not installed", func(t *testing.T) {
|
t.Run("should return failure when plugin is not installed", func(t *testing.T) {
|
||||||
@@ -164,33 +151,75 @@ func TestCheck_Run(t *testing.T) {
|
|||||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
mockPluginClient := &MockPluginClient{err: plugins.ErrPluginNotRegistered}
|
mockPluginClient := &MockPluginClient{err: plugins.ErrPluginNotRegistered}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: true}
|
||||||
|
|
||||||
check := &check{
|
check := &check{
|
||||||
DatasourceSvc: mockDatasourceSvc,
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
PluginContextProvider: mockPluginContextProvider,
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
PluginClient: mockPluginClient,
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
log: log.New("advisor.datasourcecheck"),
|
log: log.New("advisor.datasourcecheck"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
failures, err := runChecks(check)
|
||||||
items, err := check.Items(ctx)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
failures := []advisor.CheckReportFailure{}
|
|
||||||
for _, step := range check.Steps() {
|
|
||||||
for _, item := range items {
|
|
||||||
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if stepFailures != nil {
|
|
||||||
failures = append(failures, *stepFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, len(items))
|
|
||||||
assert.Len(t, failures, 1)
|
assert.Len(t, failures, 1)
|
||||||
assert.Equal(t, "health-check", failures[0].StepID)
|
assert.Equal(t, "health-check", failures[0].StepID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("should return failure when plugin is not installed and the plugin is available in the repo", func(t *testing.T) {
|
||||||
|
datasources := []*datasources.DataSource{
|
||||||
|
{UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"},
|
||||||
|
}
|
||||||
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
|
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: true}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: false}
|
||||||
|
|
||||||
|
check := &check{
|
||||||
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
|
log: log.New("advisor.datasourcecheck"),
|
||||||
|
}
|
||||||
|
|
||||||
|
failures, err := runChecks(check)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, failures, 1)
|
||||||
|
assert.Equal(t, MissingPluginStepID, failures[0].StepID)
|
||||||
|
assert.Len(t, failures[0].Links, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should return failure when plugin is not installed and the plugin is not available in the repo", func(t *testing.T) {
|
||||||
|
datasources := []*datasources.DataSource{
|
||||||
|
{UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"},
|
||||||
|
}
|
||||||
|
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||||
|
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||||
|
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||||
|
mockPluginRepo := &MockPluginRepo{exists: false}
|
||||||
|
mockPluginStore := &MockPluginStore{exists: false}
|
||||||
|
|
||||||
|
check := &check{
|
||||||
|
DatasourceSvc: mockDatasourceSvc,
|
||||||
|
PluginContextProvider: mockPluginContextProvider,
|
||||||
|
PluginClient: mockPluginClient,
|
||||||
|
PluginRepo: mockPluginRepo,
|
||||||
|
PluginStore: mockPluginStore,
|
||||||
|
log: log.New("advisor.datasourcecheck"),
|
||||||
|
}
|
||||||
|
|
||||||
|
failures, err := runChecks(check)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, failures, 1)
|
||||||
|
assert.Equal(t, MissingPluginStepID, failures[0].StepID)
|
||||||
|
assert.Len(t, failures[0].Links, 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockDatasourceSvc struct {
|
type MockDatasourceSvc struct {
|
||||||
@@ -199,7 +228,7 @@ type MockDatasourceSvc struct {
|
|||||||
dss []*datasources.DataSource
|
dss []*datasources.DataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockDatasourceSvc) GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) ([]*datasources.DataSource, error) {
|
func (m *MockDatasourceSvc) GetAllDataSources(context.Context, *datasources.GetAllDataSourcesQuery) ([]*datasources.DataSource, error) {
|
||||||
return m.dss, nil
|
return m.dss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +236,7 @@ type MockPluginContextProvider struct {
|
|||||||
pCtx backend.PluginContext
|
pCtx backend.PluginContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) {
|
func (m *MockPluginContextProvider) GetWithDataSource(context.Context, string, identity.Requester, *datasources.DataSource) (backend.PluginContext, error) {
|
||||||
return m.pCtx, nil
|
return m.pCtx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +247,29 @@ type MockPluginClient struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPluginClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
func (m *MockPluginClient) CheckHealth(context.Context, *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||||
return m.res, m.err
|
return m.res, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MockPluginStore struct {
|
||||||
|
pluginstore.Store
|
||||||
|
|
||||||
|
exists bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPluginStore) Plugin(context.Context, string) (pluginstore.Plugin, bool) {
|
||||||
|
return pluginstore.Plugin{}, m.exists
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockPluginRepo struct {
|
||||||
|
repo.Service
|
||||||
|
|
||||||
|
exists bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPluginRepo) PluginInfo(context.Context, string) (*repo.PluginInfo, error) {
|
||||||
|
if !m.exists {
|
||||||
|
return nil, errors.New("plugin not found")
|
||||||
|
}
|
||||||
|
return &repo.PluginInfo{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package kinds
|
package kinds
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
|
v0 "github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
|
v1 "github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
|
v2 "github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status is the shared status of all dashboard versions.
|
// Status is the shared status of all dashboard versions.
|
||||||
@@ -51,19 +51,19 @@ dashboard: {
|
|||||||
versions: {
|
versions: {
|
||||||
"v0alpha1": {
|
"v0alpha1": {
|
||||||
schema: {
|
schema: {
|
||||||
spec: v0alpha1.DashboardSpec
|
spec: v0.DashboardSpec
|
||||||
status: DashboardStatus
|
status: DashboardStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"v1alpha1": {
|
"v1alpha1": {
|
||||||
schema: {
|
schema: {
|
||||||
spec: v1alpha1.DashboardSpec
|
spec: v1.DashboardSpec
|
||||||
status: DashboardStatus
|
status: DashboardStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"v2alpha1": {
|
"v2alpha1": {
|
||||||
schema: {
|
schema: {
|
||||||
spec: v2alpha1.DashboardSpec
|
spec: v2.DashboardSpec
|
||||||
status: DashboardStatus
|
status: DashboardStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,6 +495,11 @@ RowRepeatOptions: {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TabRepeatOptions: {
|
||||||
|
mode: RepeatMode
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
AutoGridRepeatOptions: {
|
AutoGridRepeatOptions: {
|
||||||
mode: RepeatMode
|
mode: RepeatMode
|
||||||
value: string
|
value: string
|
||||||
@@ -523,8 +528,8 @@ GridLayoutRowSpec: {
|
|||||||
y: int
|
y: int
|
||||||
collapsed: bool
|
collapsed: bool
|
||||||
title: string
|
title: string
|
||||||
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
|
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
|
||||||
repeat?: RowRepeatOptions
|
repeat?: RowRepeatOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
GridLayoutSpec: {
|
GridLayoutSpec: {
|
||||||
@@ -604,6 +609,7 @@ TabsLayoutTabSpec: {
|
|||||||
title?: string
|
title?: string
|
||||||
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
|
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
|
||||||
conditionalRendering?: ConditionalRenderingGroupKind
|
conditionalRendering?: ConditionalRenderingGroupKind
|
||||||
|
repeat?: TabRepeatOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelSpec: {
|
PanelSpec: {
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@ type DashboardTabsLayoutTabSpec struct {
|
|||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
Layout DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
|
Layout DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
|
||||||
ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"`
|
ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"`
|
||||||
|
Repeat *DashboardTabRepeatOptions `json:"repeat,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object.
|
// NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object.
|
||||||
@@ -1078,6 +1079,17 @@ func NewDashboardTabsLayoutTabSpec() *DashboardTabsLayoutTabSpec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// +k8s:openapi-gen=true
|
||||||
|
type DashboardTabRepeatOptions struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDashboardTabRepeatOptions creates a new DashboardTabRepeatOptions object.
|
||||||
|
func NewDashboardTabRepeatOptions() *DashboardTabRepeatOptions {
|
||||||
|
return &DashboardTabRepeatOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
// Links with references to other dashboards or external resources
|
// Links with references to other dashboards or external resources
|
||||||
// +k8s:openapi-gen=true
|
// +k8s:openapi-gen=true
|
||||||
type DashboardDashboardLink struct {
|
type DashboardDashboardLink struct {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
|||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref),
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref),
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref),
|
||||||
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref),
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref),
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref),
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref),
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref),
|
||||||
@@ -3921,6 +3922,33 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref common.Refe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
|
return common.OpenAPIDefinition{
|
||||||
|
Schema: spec.Schema{
|
||||||
|
SchemaProps: spec.SchemaProps{
|
||||||
|
Type: []string{"object"},
|
||||||
|
Properties: map[string]spec.Schema{
|
||||||
|
"mode": {
|
||||||
|
SchemaProps: spec.SchemaProps{
|
||||||
|
Default: "",
|
||||||
|
Type: []string{"string"},
|
||||||
|
Format: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
SchemaProps: spec.SchemaProps{
|
||||||
|
Default: "",
|
||||||
|
Type: []string{"string"},
|
||||||
|
Format: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"mode", "value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
@@ -4027,12 +4055,17 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref common.Re
|
|||||||
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind"),
|
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"repeat": {
|
||||||
|
SchemaProps: spec.SchemaProps{
|
||||||
|
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Required: []string{"layout"},
|
Required: []string{"layout"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dependencies: []string{
|
Dependencies: []string{
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"},
|
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,55 +4,55 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/conversion"
|
"k8s.io/apimachinery/pkg/conversion"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
|
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterConversions(s *runtime.Scheme) error {
|
func RegisterConversions(s *runtime.Scheme) error {
|
||||||
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V0_to_V1(a.(*v0alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
|
return Convert_V0_to_V1(a.(*dashv0.Dashboard), b.(*dashv1.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V0_to_V2(a.(*v0alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
|
return Convert_V0_to_V2(a.(*dashv0.Dashboard), b.(*dashv2.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V1_to_V0(a.(*v1alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
|
return Convert_V1_to_V0(a.(*dashv1.Dashboard), b.(*dashv0.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V1_to_V2(a.(*v1alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
|
return Convert_V1_to_V2(a.(*dashv1.Dashboard), b.(*dashv2.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V2_to_V0(a.(*v2alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
|
return Convert_V2_to_V0(a.(*dashv2.Dashboard), b.(*dashv0.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
|
||||||
return Convert_V2_to_V1(a.(*v2alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
|
return Convert_V2_to_V1(a.(*dashv2.Dashboard), b.(*dashv1.Dashboard), scope)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V0_to_V1(in *v0alpha1.Dashboard, out *v1alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V0_to_V1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
out.Spec.Object = in.Spec.Object
|
out.Spec.Object = in.Spec.Object
|
||||||
|
|
||||||
out.Status = v1alpha1.DashboardStatus{
|
out.Status = dashv1.DashboardStatus{
|
||||||
Conversion: &v1alpha1.DashboardConversionStatus{
|
Conversion: &dashv1.DashboardConversionStatus{
|
||||||
StoredVersion: v0alpha1.VERSION,
|
StoredVersion: dashv0.VERSION,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ func Convert_V0_to_V1(in *v0alpha1.Dashboard, out *v1alpha1.Dashboard, scope con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V0_to_V2(in *dashv0.Dashboard, out *dashv2.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
// TODO (@radiohead): implement V0 to V2 conversion
|
// TODO (@radiohead): implement V0 to V2 conversion
|
||||||
@@ -77,16 +77,16 @@ func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
|
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
|
||||||
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
|
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
|
||||||
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
|
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
|
||||||
Kind: "GridLayout",
|
Kind: "GridLayout",
|
||||||
Spec: v2alpha1.DashboardGridLayoutSpec{},
|
Spec: dashv2.DashboardGridLayoutSpec{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Status = v2alpha1.DashboardStatus{
|
out.Status = dashv2.DashboardStatus{
|
||||||
Conversion: &v2alpha1.DashboardConversionStatus{
|
Conversion: &dashv2.DashboardConversionStatus{
|
||||||
StoredVersion: v0alpha1.VERSION,
|
StoredVersion: dashv0.VERSION,
|
||||||
Failed: true,
|
Failed: true,
|
||||||
Error: "backend conversion not yet implemented",
|
Error: "backend conversion not yet implemented",
|
||||||
},
|
},
|
||||||
@@ -95,21 +95,21 @@ func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V1_to_V0(in *v1alpha1.Dashboard, out *v0alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
out.Spec.Object = in.Spec.Object
|
out.Spec.Object = in.Spec.Object
|
||||||
|
|
||||||
out.Status = v0alpha1.DashboardStatus{
|
out.Status = dashv0.DashboardStatus{
|
||||||
Conversion: &v0alpha1.DashboardConversionStatus{
|
Conversion: &dashv0.DashboardConversionStatus{
|
||||||
StoredVersion: v1alpha1.VERSION,
|
StoredVersion: dashv1.VERSION,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V1_to_V2(in *dashv1.Dashboard, out *dashv2.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
// TODO (@radiohead): implement V1 to V2 conversion
|
// TODO (@radiohead): implement V1 to V2 conversion
|
||||||
@@ -122,16 +122,16 @@ func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
|
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
|
||||||
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
|
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
|
||||||
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
|
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
|
||||||
Kind: "GridLayout",
|
Kind: "GridLayout",
|
||||||
Spec: v2alpha1.DashboardGridLayoutSpec{},
|
Spec: dashv2.DashboardGridLayoutSpec{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Status = v2alpha1.DashboardStatus{
|
out.Status = dashv2.DashboardStatus{
|
||||||
Conversion: &v2alpha1.DashboardConversionStatus{
|
Conversion: &dashv2.DashboardConversionStatus{
|
||||||
StoredVersion: v1alpha1.VERSION,
|
StoredVersion: dashv1.VERSION,
|
||||||
Failed: true,
|
Failed: true,
|
||||||
Error: "backend conversion not yet implemented",
|
Error: "backend conversion not yet implemented",
|
||||||
},
|
},
|
||||||
@@ -140,14 +140,14 @@ func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V2_to_V0(in *v2alpha1.Dashboard, out *v0alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V2_to_V0(in *dashv2.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
// TODO: implement V2 to V0 conversion
|
// TODO: implement V2 to V0 conversion
|
||||||
|
|
||||||
out.Status = v0alpha1.DashboardStatus{
|
out.Status = dashv0.DashboardStatus{
|
||||||
Conversion: &v0alpha1.DashboardConversionStatus{
|
Conversion: &dashv0.DashboardConversionStatus{
|
||||||
StoredVersion: v2alpha1.VERSION,
|
StoredVersion: dashv2.VERSION,
|
||||||
Failed: true,
|
Failed: true,
|
||||||
Error: "backend conversion not yet implemented",
|
Error: "backend conversion not yet implemented",
|
||||||
},
|
},
|
||||||
@@ -156,14 +156,14 @@ func Convert_V2_to_V0(in *v2alpha1.Dashboard, out *v0alpha1.Dashboard, scope con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Convert_V2_to_V1(in *v2alpha1.Dashboard, out *v1alpha1.Dashboard, scope conversion.Scope) error {
|
func Convert_V2_to_V1(in *dashv2.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error {
|
||||||
out.ObjectMeta = in.ObjectMeta
|
out.ObjectMeta = in.ObjectMeta
|
||||||
|
|
||||||
// TODO: implement V2 to V1 conversion
|
// TODO: implement V2 to V1 conversion
|
||||||
|
|
||||||
out.Status = v1alpha1.DashboardStatus{
|
out.Status = dashv1.DashboardStatus{
|
||||||
Conversion: &v1alpha1.DashboardConversionStatus{
|
Conversion: &dashv1.DashboardConversionStatus{
|
||||||
StoredVersion: v2alpha1.VERSION,
|
StoredVersion: dashv2.VERSION,
|
||||||
Failed: true,
|
Failed: true,
|
||||||
Error: "backend conversion not yet implemented",
|
Error: "backend conversion not yet implemented",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import (
|
|||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
||||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConversionMatrixExist(t *testing.T) {
|
func TestConversionMatrixExist(t *testing.T) {
|
||||||
versions := []v1.Object{
|
versions := []v1.Object{
|
||||||
&v0alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
|
&dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
|
||||||
&v1alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
|
&dashv1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
|
||||||
&v2alpha1.Dashboard{Spec: v2alpha1.DashboardSpec{Title: "dashboardV2"}},
|
&dashv2.Dashboard{Spec: dashv2.DashboardSpec{Title: "dashboardV2"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
scheme := runtime.NewScheme()
|
scheme := runtime.NewScheme()
|
||||||
@@ -47,7 +47,7 @@ func TestConversionMatrixExist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeepCopyValid(t *testing.T) {
|
func TestDeepCopyValid(t *testing.T) {
|
||||||
dash1 := &v0alpha1.Dashboard{}
|
dash1 := &dashv0.Dashboard{}
|
||||||
meta1, err := utils.MetaAccessor(dash1)
|
meta1, err := utils.MetaAccessor(dash1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
meta1.SetFolder("f1")
|
meta1.SetFolder("f1")
|
||||||
|
|||||||
@@ -472,6 +472,9 @@ default_home_dashboard_path =
|
|||||||
# Dashboards UIDs to report performance metrics for. * can be used to report metrics for all dashboards
|
# Dashboards UIDs to report performance metrics for. * can be used to report metrics for all dashboards
|
||||||
dashboard_performance_metrics =
|
dashboard_performance_metrics =
|
||||||
|
|
||||||
|
# Maximum number of series that will be showed in a single panel. Users can opt in to rendering all series. Default is 0 (unlimited).
|
||||||
|
panel_series_limit =
|
||||||
|
|
||||||
################################### Data sources #########################
|
################################### Data sources #########################
|
||||||
[datasources]
|
[datasources]
|
||||||
# Upper limit of data sources that Grafana will return. This limit is a temporary configuration and it will be deprecated when pagination will be introduced on the list data sources API.
|
# Upper limit of data sources that Grafana will return. This limit is a temporary configuration and it will be deprecated when pagination will be introduced on the list data sources API.
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ Experimental features might be changed or removed without prior notice.
|
|||||||
| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics |
|
| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics |
|
||||||
| `exploreLogsLimitedTimeRange` | Used in Logs Drilldown to limit the time range |
|
| `exploreLogsLimitedTimeRange` | Used in Logs Drilldown to limit the time range |
|
||||||
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
||||||
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
|
|
||||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||||
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
||||||
| `timeRangeProvider` | Enables time pickers sync |
|
| `timeRangeProvider` | Enables time pickers sync |
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
|
||||||
|
import { e2e } from '../utils';
|
||||||
|
|
||||||
|
describe('Dashboard with data links that have no slug', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not reload if linking to same dashboard', () => {
|
||||||
|
cy.intercept({
|
||||||
|
pathname: '/api/ds/query',
|
||||||
|
}).as('query');
|
||||||
|
|
||||||
|
e2e.flows.importDashboard(testDashboard, 1000, true);
|
||||||
|
cy.wait('@query');
|
||||||
|
|
||||||
|
e2e.components.Panels.Panel.title('Data links without slug').should('exist');
|
||||||
|
|
||||||
|
e2e.components.DataLinksContextMenu.singleLink().contains('9yy21uzzxypg').click();
|
||||||
|
cy.contains('Loading', { timeout: 500 })
|
||||||
|
.should(() => {}) // prevent test from failing if it does not find loading
|
||||||
|
.then(throwIfLoadingFound);
|
||||||
|
cy.url().should('include', urlShouldContain);
|
||||||
|
|
||||||
|
e2e.components.DataLinksContextMenu.singleLink().contains('dr199bpvpcru').click();
|
||||||
|
cy.contains('Loading', { timeout: 500 })
|
||||||
|
.should(() => {}) // prevent test from failing if it does not find loading
|
||||||
|
.then(throwIfLoadingFound);
|
||||||
|
cy.url().should('include', urlShouldContain);
|
||||||
|
|
||||||
|
e2e.components.DataLinksContextMenu.singleLink().contains('dre33fzyxcrz').click();
|
||||||
|
cy.contains('Loading', { timeout: 500 })
|
||||||
|
.should(() => {}) // prevent test from failing if it does not find loading
|
||||||
|
.then(throwIfLoadingFound);
|
||||||
|
cy.url().should('include', urlShouldContain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlShouldContain = '/d/data-link-no-slug/data-link-without-slug-test';
|
||||||
|
|
||||||
|
const throwIfLoadingFound = (el: JQuery) => {
|
||||||
|
if (el.length) {
|
||||||
|
// This means dashboard refreshes when clicking self-referencing data link
|
||||||
|
// that has no slug in it
|
||||||
|
throw new Error('Should not contain Loading');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": 135,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-testdata-datasource",
|
||||||
|
"uid": "PD8C576611E62080A"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"url": "/d/${__dashboard.uid}?var-instance=${__data.fields.test1}&${__url_time_range}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": ["sum"],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.6.0-pre",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"alias": "test1",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-testdata-datasource",
|
||||||
|
"uid": "PD8C576611E62080A"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"scenarioId": "csv_metric_values",
|
||||||
|
"stringInput": "9wvfgzurfzb, 9yy21uzzxypg, dr199bpvpcru, dre33fzyxcrz, gc6j7crvrcpf, u6g9zuxvxypv"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Data links without slug",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "gdev-prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.6.0-pre",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "gdev-prometheus"
|
||||||
|
},
|
||||||
|
"disableTextWrap": false,
|
||||||
|
"editorMode": "builder",
|
||||||
|
"expr": "counters_logins{geohash=\"$instance\"}",
|
||||||
|
"fullMetaSearch": false,
|
||||||
|
"includeNullMetadata": true,
|
||||||
|
"instant": false,
|
||||||
|
"legendFormat": "__auto",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"useBackend": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Panel Title",
|
||||||
|
"type": "timeseries"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preload": false,
|
||||||
|
"refresh": "",
|
||||||
|
"schemaVersion": 41,
|
||||||
|
"tags": [],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"text": "9wvfgzurfzb",
|
||||||
|
"value": "9wvfgzurfzb"
|
||||||
|
},
|
||||||
|
"name": "instance",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"selected": true,
|
||||||
|
"text": "9wvfgzurfzb",
|
||||||
|
"value": "9wvfgzurfzb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "9yy21uzzxypg",
|
||||||
|
"value": "9yy21uzzxypg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "dr199bpvpcru",
|
||||||
|
"value": "dr199bpvpcru"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "dre33fzyxcrz",
|
||||||
|
"value": "dre33fzyxcrz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "gc6j7crvrcpf",
|
||||||
|
"value": "gc6j7crvrcpf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "u6g9zuxvxypv",
|
||||||
|
"value": "u6g9zuxvxypv"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"query": "9wvfgzurfzb, 9yy21uzzxypg, dr199bpvpcru, dre33fzyxcrz, gc6j7crvrcpf, u6g9zuxvxypv",
|
||||||
|
"type": "custom"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "utc",
|
||||||
|
"title": "Data Link without slug test",
|
||||||
|
"uid": "data-link-no-slug",
|
||||||
|
"version": 3
|
||||||
|
}
|
||||||
@@ -215,7 +215,7 @@ require (
|
|||||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad
|
github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad
|
||||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/grafana-app-platform-squad
|
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/grafana-app-platform-squad
|
||||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250401081501-6af5fbf3fff0 // @grafana/grafana-app-platform-squad
|
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250401081501-6af5fbf3fff0 // @grafana/grafana-app-platform-squad
|
||||||
github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 // @grafana/grafana-search-and-storage
|
github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 // indirect; @grafana/grafana-search-and-storage
|
||||||
github.com/grafana/grafana/pkg/apis/secret v0.0.0-20250319110241-5a004939da2a // @grafana/grafana-operator-experience-squad
|
github.com/grafana/grafana/pkg/apis/secret v0.0.0-20250319110241-5a004939da2a // @grafana/grafana-operator-experience-squad
|
||||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250325075903-77fa2271be7a // @grafana/grafana-app-platform-squad
|
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250325075903-77fa2271be7a // @grafana/grafana-app-platform-squad
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export interface GrafanaConfig {
|
|||||||
rudderstackIntegrationsUrl: string | undefined;
|
rudderstackIntegrationsUrl: string | undefined;
|
||||||
analyticsConsoleReporting: boolean;
|
analyticsConsoleReporting: boolean;
|
||||||
dashboardPerformanceMetrics: string[];
|
dashboardPerformanceMetrics: string[];
|
||||||
|
panelSeriesLimit: number;
|
||||||
sqlConnectionLimits: SqlConnectionLimits;
|
sqlConnectionLimits: SqlConnectionLimits;
|
||||||
sharedWithMeFolderUID?: string;
|
sharedWithMeFolderUID?: string;
|
||||||
rootFolderUID?: string;
|
rootFolderUID?: string;
|
||||||
@@ -246,6 +247,7 @@ export interface GrafanaConfig {
|
|||||||
* Grafana's supported language.
|
* Grafana's supported language.
|
||||||
*/
|
*/
|
||||||
language: string | undefined;
|
language: string | undefined;
|
||||||
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SqlConnectionLimits {
|
export interface SqlConnectionLimits {
|
||||||
|
|||||||
@@ -725,10 +725,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
appPlatformGrpcClientAuth?: boolean;
|
appPlatformGrpcClientAuth?: boolean;
|
||||||
/**
|
/**
|
||||||
* Enable the app sidecar feature that allows rendering 2 apps at the same time
|
|
||||||
*/
|
|
||||||
appSidecar?: boolean;
|
|
||||||
/**
|
|
||||||
* Enable the groupsync extension for managing Group Attribute Sync feature
|
* Enable the groupsync extension for managing Group Attribute Sync feature
|
||||||
*/
|
*/
|
||||||
groupAttributeSync?: boolean;
|
groupAttributeSync?: boolean;
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ export const availableIconsIndex = {
|
|||||||
'add-user': true,
|
'add-user': true,
|
||||||
attach: true,
|
attach: true,
|
||||||
'dollar-alt': true,
|
'dollar-alt': true,
|
||||||
|
'ai-sparkle': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IconName = keyof typeof availableIconsIndex;
|
export type IconName = keyof typeof availableIconsIndex;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { PluginMeta, PluginType } from '../types/plugin';
|
||||||
|
|
||||||
|
import { matchPluginId } from './matchPluginId';
|
||||||
|
|
||||||
|
const createPluginMeta = (id: string, aliasIDs?: string[]): PluginMeta => ({
|
||||||
|
id,
|
||||||
|
name: 'Test Plugin',
|
||||||
|
type: PluginType.datasource,
|
||||||
|
module: 'test',
|
||||||
|
baseUrl: 'test',
|
||||||
|
info: {
|
||||||
|
author: { name: 'Test' },
|
||||||
|
description: 'Test',
|
||||||
|
links: [],
|
||||||
|
logos: { small: '', large: '' },
|
||||||
|
screenshots: [],
|
||||||
|
updated: '',
|
||||||
|
version: '',
|
||||||
|
},
|
||||||
|
aliasIDs,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchPluginId', () => {
|
||||||
|
it('should match exact plugin ID', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin');
|
||||||
|
expect(matchPluginId('test-plugin', pluginMeta)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match different plugin ID', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin');
|
||||||
|
expect(matchPluginId('different-plugin', pluginMeta)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match Amazon Prometheus flavor when idToMatch is prometheus', () => {
|
||||||
|
const pluginMeta = createPluginMeta('grafana-amazonprometheus-datasource');
|
||||||
|
expect(matchPluginId('prometheus', pluginMeta)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match Azure Prometheus flavor when idToMatch is prometheus', () => {
|
||||||
|
const pluginMeta = createPluginMeta('grafana-azureprometheus-datasource');
|
||||||
|
expect(matchPluginId('prometheus', pluginMeta)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match non-prometheus flavor when idToMatch is prometheus', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin');
|
||||||
|
expect(matchPluginId('prometheus', pluginMeta)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match alias IDs', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin', ['alias1', 'alias2']);
|
||||||
|
expect(matchPluginId('alias1', pluginMeta)).toBe(true);
|
||||||
|
expect(matchPluginId('alias2', pluginMeta)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match non-existent alias ID', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin', ['alias1', 'alias2']);
|
||||||
|
expect(matchPluginId('alias3', pluginMeta)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined aliasIDs', () => {
|
||||||
|
const pluginMeta = createPluginMeta('test-plugin');
|
||||||
|
expect(matchPluginId('alias1', pluginMeta)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,21 @@ export function matchPluginId(idToMatch: string, pluginMeta: PluginMeta) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPromFlavor(idToMatch)) {
|
||||||
|
return isPromFlavor(pluginMeta.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (pluginMeta.aliasIDs) {
|
if (pluginMeta.aliasIDs) {
|
||||||
return pluginMeta.aliasIDs.includes(idToMatch);
|
return pluginMeta.aliasIDs.includes(idToMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPromFlavor(pluginId: string): boolean {
|
||||||
|
if (pluginId === 'prometheus') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const regex = new RegExp('^grafana-[0-9a-z]+prometheus-datasource$');
|
||||||
|
return regex.test(pluginId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export function MetricSelect({
|
|||||||
return (
|
return (
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect}
|
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect}
|
||||||
isClearable={Boolean(variableEditor)}
|
isClearable={true}
|
||||||
inputId="prometheus-metric-select"
|
inputId="prometheus-metric-select"
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={query.metric ? toOption(query.metric) : undefined}
|
value={query.metric ? toOption(query.metric) : undefined}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
rudderstackIntegrationsUrl: undefined;
|
rudderstackIntegrationsUrl: undefined;
|
||||||
analyticsConsoleReporting = false;
|
analyticsConsoleReporting = false;
|
||||||
dashboardPerformanceMetrics: string[] = [];
|
dashboardPerformanceMetrics: string[] = [];
|
||||||
|
panelSeriesLimit = 0;
|
||||||
sqlConnectionLimits = {
|
sqlConnectionLimits = {
|
||||||
maxOpenConns: 100,
|
maxOpenConns: 100,
|
||||||
maxIdleConns: 100,
|
maxIdleConns: 100,
|
||||||
@@ -208,6 +209,12 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
*/
|
*/
|
||||||
language: string | undefined;
|
language: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale used in Grafana's UI. Default to 'es-US' in the backend and overwritten when the user select a different one in SharedPreferences.
|
||||||
|
* This is the locale that is used for date formatting and other locale-specific features.
|
||||||
|
*/
|
||||||
|
locale: string;
|
||||||
|
|
||||||
constructor(options: GrafanaBootConfig) {
|
constructor(options: GrafanaBootConfig) {
|
||||||
this.bootData = options.bootData;
|
this.bootData = options.bootData;
|
||||||
|
|
||||||
@@ -243,6 +250,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
this.theme2 = getThemeById(this.bootData.user.theme);
|
this.theme2 = getThemeById(this.bootData.user.theme);
|
||||||
this.bootData.user.lightTheme = this.theme2.isLight;
|
this.bootData.user.lightTheme = this.theme2.isLight;
|
||||||
this.theme = this.theme2.v1;
|
this.theme = this.theme2.v1;
|
||||||
|
|
||||||
|
this.locale = options.bootData.user.locale;
|
||||||
}
|
}
|
||||||
geomapDefaultBaseLayer?: MapLayerOptions<any> | undefined;
|
geomapDefaultBaseLayer?: MapLayerOptions<any> | undefined;
|
||||||
listDashboardScopesEndpoint?: string | undefined;
|
listDashboardScopesEndpoint?: string | undefined;
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ export {
|
|||||||
setGetObservablePluginLinks,
|
setGetObservablePluginLinks,
|
||||||
type GetObservablePluginLinks,
|
type GetObservablePluginLinks,
|
||||||
} from '../services/pluginExtensions/getObservablePluginLinks';
|
} from '../services/pluginExtensions/getObservablePluginLinks';
|
||||||
|
|
||||||
|
export { UserStorage } from '../utils/userStorage';
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { createContext, useContext } from 'react';
|
|
||||||
import { useObservable } from 'react-use';
|
|
||||||
|
|
||||||
import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
|
||||||
|
|
||||||
export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>(
|
|
||||||
sidecarServiceSingleton_EXPERIMENTAL
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the
|
|
||||||
* service props so that even though they are observables we just pass actual values to the components.
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
export function useSidecar_EXPERIMENTAL() {
|
|
||||||
// As the sidecar service functionality is behind feature flag this does not need to be for now
|
|
||||||
const service = useContext(SidecarContext_EXPERIMENTAL);
|
|
||||||
|
|
||||||
if (!service) {
|
|
||||||
throw new Error('No SidecarContext found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialContext = useObservable(service.initialContextObservable, service.initialContext);
|
|
||||||
const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
|
|
||||||
const locationService = service.getLocationService();
|
|
||||||
|
|
||||||
return {
|
|
||||||
activePluginId,
|
|
||||||
initialContext,
|
|
||||||
locationService,
|
|
||||||
// TODO: currently this allows anybody to open any app, in the future we should probably scope this to the
|
|
||||||
// current app but that means we will need to incorporate this better into the plugin platform APIs which
|
|
||||||
// we will do once the functionality is reasonably stable
|
|
||||||
openApp: (pluginId: string, context?: unknown) => {
|
|
||||||
return service.openApp(pluginId, context);
|
|
||||||
},
|
|
||||||
openAppV2: (pluginId: string, path?: string) => {
|
|
||||||
return service.openAppV2(pluginId, path);
|
|
||||||
},
|
|
||||||
openAppV3: (options: { pluginId: string; path?: string; follow?: boolean }) => {
|
|
||||||
return service.openAppV3(options);
|
|
||||||
},
|
|
||||||
closeApp: () => service.closeApp(),
|
|
||||||
isAppOpened: (pluginId: string) => {
|
|
||||||
return service.isAppOpened(pluginId);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import * as H from 'history';
|
|
||||||
|
|
||||||
import { config } from '../config';
|
|
||||||
|
|
||||||
import { HistoryWrapper } from './LocationService';
|
|
||||||
import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
const mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/explore'] }));
|
|
||||||
const sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
|
|
||||||
return {
|
|
||||||
mainLocationService,
|
|
||||||
sidecarService,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SidecarService_EXPERIMENTAL', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.appSidecar = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
config.featureToggles.appSidecar = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has the correct state after opening and closing an app', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
sidecarService.openApp('pluginId', { filter: 'test' });
|
|
||||||
|
|
||||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
|
||||||
expect(sidecarService.initialContext).toMatchObject({ filter: 'test' });
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId');
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.activePluginId).toBe(undefined);
|
|
||||||
expect(sidecarService.initialContext).toBe(undefined);
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has the correct state after opening and closing an app v2', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
sidecarService.openAppV2('pluginId', '/test');
|
|
||||||
|
|
||||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test');
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.activePluginId).toBe(undefined);
|
|
||||||
expect(sidecarService.initialContext).toBe(undefined);
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has the correct state after opening and closing an app v3', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId', path: '/test' });
|
|
||||||
|
|
||||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test');
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.activePluginId).toBe(undefined);
|
|
||||||
expect(sidecarService.initialContext).toBe(undefined);
|
|
||||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports correct opened state', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
|
|
||||||
sidecarService.openApp('pluginId');
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports correct opened state v2', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
|
|
||||||
sidecarService.openAppV2('pluginId');
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports correct opened state v3', () => {
|
|
||||||
const { sidecarService } = setup();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
|
|
||||||
sidecarService.closeApp();
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('autocloses on not allowed routes', () => {
|
|
||||||
const { sidecarService, mainLocationService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
mainLocationService.push('/config');
|
|
||||||
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('autocloses on when changing route', () => {
|
|
||||||
const { sidecarService, mainLocationService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
mainLocationService.push('/a/other-app');
|
|
||||||
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not autocloses when set to follow', () => {
|
|
||||||
const { sidecarService, mainLocationService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
mainLocationService.push('/a/other-app');
|
|
||||||
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('autocloses on not allowed routes when set to follow', () => {
|
|
||||||
const { sidecarService, mainLocationService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
mainLocationService.push('/config');
|
|
||||||
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('autocloses on not allowed routes when set to follow', () => {
|
|
||||||
const { sidecarService, mainLocationService } = setup();
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
mainLocationService.push('/config');
|
|
||||||
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens sidecar even if starting route is not allowed and then it changes', () => {
|
|
||||||
const mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/login'] }));
|
|
||||||
const sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
|
|
||||||
mainLocationService.push('/explore');
|
|
||||||
|
|
||||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
|
||||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import * as H from 'history';
|
|
||||||
import { pick } from 'lodash';
|
|
||||||
import { BehaviorSubject, map, Observable } from 'rxjs';
|
|
||||||
|
|
||||||
import { reportInteraction } from '../analytics/utils';
|
|
||||||
import { config } from '../config';
|
|
||||||
|
|
||||||
import { HistoryWrapper, locationService as mainLocationService, LocationService } from './LocationService';
|
|
||||||
|
|
||||||
// Only allow sidecar to be opened on these routes. It does not seem to make sense to keep the sidecar opened on
|
|
||||||
// config/admin pages for example.
|
|
||||||
// At this moment let's be restrictive about where the sidecar can show and add more routes if there is a need.
|
|
||||||
const ALLOW_ROUTES = [
|
|
||||||
/(^\/d\/)/, // dashboards
|
|
||||||
/^\/explore/, // explore + explore metrics
|
|
||||||
/^\/a\/[^\/]+/, // app plugins
|
|
||||||
/^\/alerting/,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana).
|
|
||||||
* At this moment this is highly experimental and if used should be understood to break easily with newer versions.
|
|
||||||
* None of this functionality works without a feature toggle `appSidecar` being enabled.
|
|
||||||
*
|
|
||||||
* Right now this being in a single service is more of a practical tradeoff for easier isolation in the future these
|
|
||||||
* APIs may be integrated into other services or features like app extensions, plugin system etc.
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
export class SidecarService_EXPERIMENTAL {
|
|
||||||
private _initialContext: BehaviorSubject<unknown | undefined>;
|
|
||||||
|
|
||||||
private sidecarLocationService: LocationService;
|
|
||||||
private mainLocationService: LocationService;
|
|
||||||
|
|
||||||
// If true we don't close the sidecar when user navigates to another app or part of Grafana from where the sidecar
|
|
||||||
// was opened.
|
|
||||||
private follow = false;
|
|
||||||
|
|
||||||
// Keep track of where the sidecar was originally opened for autoclose behaviour.
|
|
||||||
private mainLocationWhenOpened: string | undefined;
|
|
||||||
|
|
||||||
private mainOnAllowedRoute = false;
|
|
||||||
|
|
||||||
constructor(mainLocationService: LocationService) {
|
|
||||||
this._initialContext = new BehaviorSubject<unknown | undefined>(undefined);
|
|
||||||
this.mainLocationService = mainLocationService;
|
|
||||||
this.sidecarLocationService = new HistoryWrapper(
|
|
||||||
createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' })
|
|
||||||
);
|
|
||||||
this.handleMainLocationChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertFeatureEnabled() {
|
|
||||||
if (!config.featureToggles.appSidecar) {
|
|
||||||
console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateMainLocationWhenOpened() {
|
|
||||||
const pathname = this.mainLocationService.getLocation().pathname;
|
|
||||||
for (const route of ALLOW_ROUTES) {
|
|
||||||
const match = pathname.match(route)?.[0];
|
|
||||||
if (match) {
|
|
||||||
this.mainLocationWhenOpened = match;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every time the main location changes we check if we should keep the sidecar open or close it based on list
|
|
||||||
* of allowed routes and also based on the follow flag when opening the app.
|
|
||||||
*/
|
|
||||||
private handleMainLocationChanges() {
|
|
||||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) =>
|
|
||||||
this.mainLocationService.getLocation().pathname.match(prefix)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.mainLocationService.getLocationObservable().subscribe((location) => {
|
|
||||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) => location.pathname.match(prefix));
|
|
||||||
|
|
||||||
if (!this.activePluginId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.mainOnAllowedRoute) {
|
|
||||||
this.closeApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We check if we moved to some other app or part of grafana from where we opened the sidecar.
|
|
||||||
const isTheSameLocation = Boolean(
|
|
||||||
this.mainLocationWhenOpened && location.pathname.startsWith(this.mainLocationWhenOpened)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(isTheSameLocation || this.follow)) {
|
|
||||||
this.closeApp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current app id of the app in sidecar. This is most probably provisional. In the future
|
|
||||||
* this should be driven by URL addressing so that routing for the apps don't change. Useful just internally
|
|
||||||
* to decide which app to render.
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
get activePluginIdObservable() {
|
|
||||||
return this.sidecarLocationService.getLocationObservable().pipe(
|
|
||||||
map((val) => {
|
|
||||||
return getPluginIdFromUrl(val?.pathname || '');
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get initial context which is whatever data was passed when calling the 'openApp' function. This is meant as
|
|
||||||
* a way for the app to initialize it's state based on some context that is passed to it from the primary app.
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
get initialContextObservable() {
|
|
||||||
return this._initialContext.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current value of the subject, this is needed if we want the value immediately. For example if used in
|
|
||||||
// hook in react with useObservable first render would return undefined even if the behaviourSubject has some
|
|
||||||
// value which will be emitted in the next tick and thus next rerender.
|
|
||||||
get initialContext() {
|
|
||||||
return this._initialContext.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
get activePluginId() {
|
|
||||||
return getPluginIdFromUrl(this.sidecarLocationService.getLocation().pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocationService() {
|
|
||||||
return this.sidecarLocationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens an app in a sidecar. You can also pass some context object that will be then available to the app.
|
|
||||||
* @deprecated
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
openApp(pluginId: string, context?: unknown) {
|
|
||||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._initialContext.next(context);
|
|
||||||
this.openAppV3({ pluginId, follow: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
|
||||||
* @deprecated
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
openAppV2(pluginId: string, path?: string) {
|
|
||||||
this.openAppV3({ pluginId, path, follow: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
|
||||||
* @param options.pluginId Plugin ID of the app to open
|
|
||||||
* @param options.path Relative path inside the app to open
|
|
||||||
* @param options.follow If true, the sidecar will stay open even if the main location change to another app or
|
|
||||||
* Grafana section
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
openAppV3(options: { pluginId: string; path?: string; follow?: boolean }) {
|
|
||||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.follow = options.follow || false;
|
|
||||||
|
|
||||||
this.updateMainLocationWhenOpened();
|
|
||||||
this.sidecarLocationService.push({ pathname: `/a/${options.pluginId}${options.path || ''}` });
|
|
||||||
reportInteraction('sidecar_service_open_app', { pluginId: options.pluginId, follow: options.follow });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
closeApp() {
|
|
||||||
if (!this.assertFeatureEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.follow = false;
|
|
||||||
this.mainLocationWhenOpened = undefined;
|
|
||||||
this._initialContext.next(undefined);
|
|
||||||
this.sidecarLocationService.replace({ pathname: '/' });
|
|
||||||
|
|
||||||
reportInteraction('sidecar_service_close_app');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is mainly useful inside an app extensions which are executed outside the main app context but can work
|
|
||||||
* differently depending on whether their app is currently rendered or not.
|
|
||||||
*
|
|
||||||
* This is also true only in case a sidecar is opened. In other cases, just to check if a single app is opened
|
|
||||||
* probably does not make sense.
|
|
||||||
*
|
|
||||||
* This means these are the states and the result of this function:
|
|
||||||
* Single app is opened: false (may seem strange from considering the function name, but the main point of
|
|
||||||
* this is to recognize when the app needs to do specific alteration in context of running next to second app)
|
|
||||||
* 2 apps are opened and pluginId is the one in the main window: true
|
|
||||||
* 2 apps are opened and pluginId is the one in the sidecar window: true
|
|
||||||
* 2 apps are opened and pluginId is not one of those: false
|
|
||||||
*
|
|
||||||
* @experimental
|
|
||||||
*/
|
|
||||||
isAppOpened(pluginId: string) {
|
|
||||||
if (!this.assertFeatureEnabled()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = !!(this.activePluginId && (this.activePluginId === pluginId || getMainAppPluginId() === pluginId));
|
|
||||||
reportInteraction('sidecar_service_is_app_opened', { pluginId, isOpened: result });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginIdUrlRegex = /a\/([^\/]+)/;
|
|
||||||
function getPluginIdFromUrl(url: string) {
|
|
||||||
return url.match(pluginIdUrlRegex)?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The app plugin that is "open" in the main Grafana view
|
|
||||||
function getMainAppPluginId() {
|
|
||||||
// TODO: not great but we have to get a handle on the other locationService used for the main view and easiest way
|
|
||||||
// right now is through this global singleton
|
|
||||||
const { pathname } = mainLocationService.getLocation();
|
|
||||||
|
|
||||||
// A naive way to sort of simulate core features being an app and having an appID
|
|
||||||
let mainApp = getPluginIdFromUrl(pathname);
|
|
||||||
if (!mainApp && pathname.match(/\/explore/)) {
|
|
||||||
mainApp = 'explore';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mainApp && pathname.match(/\/d\//)) {
|
|
||||||
mainApp = 'dashboards';
|
|
||||||
}
|
|
||||||
|
|
||||||
return mainApp || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalStorageHistoryOptions = {
|
|
||||||
storageKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LocationStorageHistory extends H.MemoryHistory {
|
|
||||||
getLocationObservable(): Observable<H.Location | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple wrapper over the memory history that persists the location in the localStorage.
|
|
||||||
*
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
function createLocationStorageHistory(options: LocalStorageHistoryOptions): LocationStorageHistory {
|
|
||||||
const storedLocation = localStorage.getItem(options.storageKey);
|
|
||||||
const initialEntry = storedLocation ? JSON.parse(storedLocation) : '/';
|
|
||||||
const locationSubject = new BehaviorSubject<H.Location | undefined>(initialEntry);
|
|
||||||
const memoryHistory = H.createMemoryHistory({ initialEntries: [initialEntry] });
|
|
||||||
|
|
||||||
let currentLocation = memoryHistory.location;
|
|
||||||
|
|
||||||
function maybeUpdateLocation() {
|
|
||||||
if (memoryHistory.location !== currentLocation) {
|
|
||||||
localStorage.setItem(
|
|
||||||
options.storageKey,
|
|
||||||
JSON.stringify(pick(memoryHistory.location, 'pathname', 'search', 'hash'))
|
|
||||||
);
|
|
||||||
currentLocation = memoryHistory.location;
|
|
||||||
locationSubject.next(memoryHistory.location);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This creates a sort of proxy over the memory location just to add the localStorage persistence and the location
|
|
||||||
// observer. We could achieve the same effect by a listener but that would create a memory leak as there would be no
|
|
||||||
// reasonable way to unsubcribe the listener later on.
|
|
||||||
// Another issue is that react router for some reason does not care about proper `this` binding and just calls these
|
|
||||||
// as normal functions. So if this were to be a class we would still need to bind each of these methods to the
|
|
||||||
// instance so at that moment this just seems easier.
|
|
||||||
return {
|
|
||||||
...memoryHistory,
|
|
||||||
// Getter aren't destructured as getter but as values, so they have to be still here even though we are not
|
|
||||||
// modifying them.
|
|
||||||
get index() {
|
|
||||||
return memoryHistory.index;
|
|
||||||
},
|
|
||||||
get entries() {
|
|
||||||
return memoryHistory.entries;
|
|
||||||
},
|
|
||||||
get length() {
|
|
||||||
return memoryHistory.length;
|
|
||||||
},
|
|
||||||
get action() {
|
|
||||||
return memoryHistory.action;
|
|
||||||
},
|
|
||||||
get location() {
|
|
||||||
return memoryHistory.location;
|
|
||||||
},
|
|
||||||
push(location: H.Path | H.LocationDescriptor<H.LocationState>, state?: H.LocationState) {
|
|
||||||
memoryHistory.push(location, state);
|
|
||||||
maybeUpdateLocation();
|
|
||||||
},
|
|
||||||
replace(location: H.Path | H.LocationDescriptor<H.LocationState>, state?: H.LocationState) {
|
|
||||||
memoryHistory.replace(location, state);
|
|
||||||
maybeUpdateLocation();
|
|
||||||
},
|
|
||||||
go(n: number) {
|
|
||||||
memoryHistory.go(n);
|
|
||||||
maybeUpdateLocation();
|
|
||||||
},
|
|
||||||
goBack() {
|
|
||||||
memoryHistory.goBack();
|
|
||||||
maybeUpdateLocation();
|
|
||||||
},
|
|
||||||
goForward() {
|
|
||||||
memoryHistory.goForward();
|
|
||||||
maybeUpdateLocation();
|
|
||||||
},
|
|
||||||
getLocationObservable() {
|
|
||||||
return locationSubject.asObservable();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(mainLocationService);
|
|
||||||
@@ -6,8 +6,6 @@ export * from './templateSrv';
|
|||||||
export * from './live';
|
export * from './live';
|
||||||
export * from './LocationService';
|
export * from './LocationService';
|
||||||
export * from './appEvents';
|
export * from './appEvents';
|
||||||
export * from './SidecarService_EXPERIMENTAL';
|
|
||||||
export * from './SidecarContext_EXPERIMENTAL';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
setPluginComponentHook,
|
setPluginComponentHook,
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ class MyDataSource extends DataSourceWithBackend<MyQuery, DataSourceJsonData> {
|
|||||||
applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[] | undefined): MyQuery {
|
applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[] | undefined): MyQuery {
|
||||||
return { ...query, applyTemplateVariablesCalled: true, filters };
|
return { ...query, applyTemplateVariablesCalled: true, filters };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getValue(key: string) {
|
||||||
|
return await this.userStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setValue(key: string, value: string) {
|
||||||
|
await this.userStorage.setItem(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDatasourceRequest = jest.fn<Promise<FetchResponse>, BackendSrvRequest[]>();
|
const mockDatasourceRequest = jest.fn<Promise<FetchResponse>, BackendSrvRequest[]>();
|
||||||
@@ -536,6 +544,15 @@ describe('DataSourceWithBackend', () => {
|
|||||||
expect(publicDashboardQueryHandler).toHaveBeenCalledWith(request);
|
expect(publicDashboardQueryHandler).toHaveBeenCalledWith(request);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('user storage', () => {
|
||||||
|
test('sets and gets a value', async () => {
|
||||||
|
const { ds } = createMockDatasource();
|
||||||
|
|
||||||
|
await ds.setValue('multiplier', '1');
|
||||||
|
expect(await ds.getValue('multiplier')).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockDatasource() {
|
function createMockDatasource() {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
|
|
||||||
import { publicDashboardQueryHandler } from './publicDashboardQueryHandler';
|
import { publicDashboardQueryHandler } from './publicDashboardQueryHandler';
|
||||||
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse';
|
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse';
|
||||||
|
import { UserStorage } from './userStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -121,8 +122,11 @@ class DataSourceWithBackend<
|
|||||||
TQuery extends DataQuery = DataQuery,
|
TQuery extends DataQuery = DataQuery,
|
||||||
TOptions extends DataSourceJsonData = DataSourceJsonData,
|
TOptions extends DataSourceJsonData = DataSourceJsonData,
|
||||||
> extends DataSourceApi<TQuery, TOptions> {
|
> extends DataSourceApi<TQuery, TOptions> {
|
||||||
|
protected userStorage: UserStorage;
|
||||||
|
|
||||||
constructor(instanceSettings: DataSourceInstanceSettings<TOptions>) {
|
constructor(instanceSettings: DataSourceInstanceSettings<TOptions>) {
|
||||||
super(instanceSettings);
|
super(instanceSettings);
|
||||||
|
this.userStorage = new UserStorage(instanceSettings.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,15 +48,15 @@ describe('userStorage', () => {
|
|||||||
it('use localStorage if the user is not logged in', async () => {
|
it('use localStorage if the user is not logged in', async () => {
|
||||||
config.bootData.user.isSignedIn = false;
|
config.bootData.user.isSignedIn = false;
|
||||||
const storage = usePluginUserStorage();
|
const storage = usePluginUserStorage();
|
||||||
storage.getItem('key');
|
await storage.getItem('key');
|
||||||
expect(localStorage.getItem).toHaveBeenCalled();
|
expect(localStorage.getItem).toHaveBeenCalledWith('plugin-id:abc:key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('use localStorage if the user storage is not found', async () => {
|
it('use localStorage if the user storage is not found', async () => {
|
||||||
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
|
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
|
||||||
const storage = usePluginUserStorage();
|
const storage = usePluginUserStorage();
|
||||||
await storage.getItem('key');
|
await storage.getItem('key');
|
||||||
expect(localStorage.getItem).toHaveBeenCalled();
|
expect(localStorage.getItem).toHaveBeenCalledWith('plugin-id:abc:key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the value from the user storage', async () => {
|
it('returns the value from the user storage', async () => {
|
||||||
@@ -73,8 +73,8 @@ describe('userStorage', () => {
|
|||||||
it('use localStorage if the user is not logged in', async () => {
|
it('use localStorage if the user is not logged in', async () => {
|
||||||
config.bootData.user.isSignedIn = false;
|
config.bootData.user.isSignedIn = false;
|
||||||
const storage = usePluginUserStorage();
|
const storage = usePluginUserStorage();
|
||||||
storage.setItem('key', 'value');
|
await storage.setItem('key', 'value');
|
||||||
expect(localStorage.setItem).toHaveBeenCalled();
|
expect(localStorage.setItem).toHaveBeenCalledWith('plugin-id:abc:key', 'value');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new user storage if it does not exist', async () => {
|
it('creates a new user storage if it does not exist', async () => {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ async function apiRequest<T>(requestOptions: RequestOptions) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A class for interacting with the backend user storage.
|
* A class for interacting with the backend user storage.
|
||||||
* Unexported because it is currently only be used through the useUserStorage hook.
|
* Exposed internally only to avoid misuse (wrong service name)..
|
||||||
*/
|
*/
|
||||||
class UserStorage {
|
export class UserStorage {
|
||||||
private service: string;
|
private service: string;
|
||||||
private resourceName: string;
|
private resourceName: string;
|
||||||
private userUID: string;
|
private userUID: string;
|
||||||
@@ -76,13 +76,13 @@ class UserStorage {
|
|||||||
async getItem(key: string): Promise<string | null> {
|
async getItem(key: string): Promise<string | null> {
|
||||||
if (!this.canUseUserStorage) {
|
if (!this.canUseUserStorage) {
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
return localStorage.getItem(this.resourceName);
|
return localStorage.getItem(`${this.resourceName}:${key}`);
|
||||||
}
|
}
|
||||||
// Ensure this.storageSpec is initialized
|
// Ensure this.storageSpec is initialized
|
||||||
await this.init();
|
await this.init();
|
||||||
if (!this.storageSpec) {
|
if (!this.storageSpec) {
|
||||||
// Also, fallback to localStorage for backward compatibility
|
// Also, fallback to localStorage for backward compatibility
|
||||||
return localStorage.getItem(this.resourceName);
|
return localStorage.getItem(`${this.resourceName}:${key}`);
|
||||||
}
|
}
|
||||||
return this.storageSpec.data[key];
|
return this.storageSpec.data[key];
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ class UserStorage {
|
|||||||
async setItem(key: string, value: string): Promise<void> {
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
if (!this.canUseUserStorage) {
|
if (!this.canUseUserStorage) {
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(`${this.resourceName}:${key}`, value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,11 @@ RowRepeatOptions: {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TabRepeatOptions: {
|
||||||
|
mode: RepeatMode,
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
AutoGridRepeatOptions: {
|
AutoGridRepeatOptions: {
|
||||||
mode: RepeatMode
|
mode: RepeatMode
|
||||||
value: string
|
value: string
|
||||||
@@ -603,6 +608,7 @@ TabsLayoutTabKind: {
|
|||||||
TabsLayoutTabSpec: {
|
TabsLayoutTabSpec: {
|
||||||
title?: string
|
title?: string
|
||||||
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
|
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
|
||||||
|
repeat?: TabRepeatOptions
|
||||||
conditionalRendering?: ConditionalRenderingGroupKind
|
conditionalRendering?: ConditionalRenderingGroupKind
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,4 +968,4 @@ ConditionalRenderingTimeRangeSizeKind: {
|
|||||||
|
|
||||||
ConditionalRenderingTimeRangeSizeSpec: {
|
ConditionalRenderingTimeRangeSizeSpec: {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export interface FieldConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
||||||
// This will default to `${frame.meta.path}/${field.name}
|
// This will default to `${frame.meta.path}/${field.name}
|
||||||
//
|
//
|
||||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||||
// may be used to update the results
|
// may be used to update the results
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -916,6 +916,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
|
|||||||
export interface TabsLayoutTabSpec {
|
export interface TabsLayoutTabSpec {
|
||||||
title?: string;
|
title?: string;
|
||||||
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
|
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
|
||||||
|
repeat?: TabRepeatOptions;
|
||||||
conditionalRendering?: ConditionalRenderingGroupKind;
|
conditionalRendering?: ConditionalRenderingGroupKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,6 +924,16 @@ export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
|
|||||||
layout: defaultGridLayoutKind(),
|
layout: defaultGridLayoutKind(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface TabRepeatOptions {
|
||||||
|
mode: "variable";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
|
||||||
|
mode: RepeatMode,
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
|
||||||
// Links with references to other dashboards or external resources
|
// Links with references to other dashboards or external resources
|
||||||
export interface DashboardLink {
|
export interface DashboardLink {
|
||||||
// Title to display with the link
|
// Title to display with the link
|
||||||
@@ -1492,4 +1503,3 @@ export const defaultVariableValueOption = (): VariableValueOption => ({
|
|||||||
label: "",
|
label: "",
|
||||||
value: defaultVariableValueSingle(),
|
value: defaultVariableValueSingle(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export interface FieldConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
||||||
// This will default to `${frame.meta.path}/${field.name}
|
// This will default to `${frame.meta.path}/${field.name}
|
||||||
//
|
//
|
||||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||||
// may be used to update the results
|
// may be used to update the results
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -872,12 +872,23 @@ export interface TabsLayoutTabSpec {
|
|||||||
title?: string;
|
title?: string;
|
||||||
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
|
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
|
||||||
conditionalRendering?: ConditionalRenderingGroupKind;
|
conditionalRendering?: ConditionalRenderingGroupKind;
|
||||||
|
repeat?: TabRepeatOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
|
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
|
||||||
layout: defaultGridLayoutKind(),
|
layout: defaultGridLayoutKind(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface TabRepeatOptions {
|
||||||
|
mode: "variable";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
|
||||||
|
mode: RepeatMode,
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
|
||||||
// Links with references to other dashboards or external resources
|
// Links with references to other dashboards or external resources
|
||||||
export interface DashboardLink {
|
export interface DashboardLink {
|
||||||
// Title to display with the link
|
// Title to display with the link
|
||||||
@@ -1402,4 +1413,3 @@ export const defaultSpec = (): Spec => ({
|
|||||||
title: "",
|
title: "",
|
||||||
variables: [],
|
variables: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ export const getCheckboxStyles = (theme: GrafanaTheme2, invalid = false) => {
|
|||||||
gridRowStart: 2,
|
gridRowStart: 2,
|
||||||
lineHeight: theme.typography.bodySmall.lineHeight,
|
lineHeight: theme.typography.bodySmall.lineHeight,
|
||||||
marginTop: 0 /* The margin effectively comes from the top: -2px on the label above it */,
|
marginTop: 0 /* The margin effectively comes from the top: -2px on the label above it */,
|
||||||
|
// Enable interacting with description when checkbox is disabled
|
||||||
|
zIndex: 1,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export function TableCellNG(props: TableCellNGProps) {
|
|||||||
tableCellDiv?.style.setProperty('min-height', `100%`);
|
tableCellDiv?.style.setProperty('min-height', `100%`);
|
||||||
tableCellDiv?.style.setProperty('height', `fit-content`);
|
tableCellDiv?.style.setProperty('height', `fit-content`);
|
||||||
tableCellDiv?.style.setProperty('background', colors.bgHoverColor || 'none');
|
tableCellDiv?.style.setProperty('background', colors.bgHoverColor || 'none');
|
||||||
|
tableCellDiv?.style.setProperty('min-width', 'min-content');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ export function TableCellNG(props: TableCellNGProps) {
|
|||||||
tableCellDiv?.style.removeProperty('min-height');
|
tableCellDiv?.style.removeProperty('min-height');
|
||||||
tableCellDiv?.style.removeProperty('height');
|
tableCellDiv?.style.removeProperty('height');
|
||||||
tableCellDiv?.style.removeProperty('background');
|
tableCellDiv?.style.removeProperty('background');
|
||||||
|
tableCellDiv?.style.removeProperty('min-width');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -865,28 +865,26 @@ export function mapFrameToDataGrid({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// INFO: This loop calculates the width for each column in less than a millisecond.
|
// set columns that are at minimum width
|
||||||
let sharedWidth = availableWidth / fieldCountWithoutWidth;
|
let sharedWidth = availableWidth / fieldCountWithoutWidth;
|
||||||
|
for (let i = fieldCountWithoutWidth; i > 0; i--) {
|
||||||
// First pass: Assign minimum widths to columns that need it
|
for (const column of columns) {
|
||||||
columns.forEach((column) => {
|
if (!column.width && column.minWidth! > sharedWidth) {
|
||||||
if (!column.width && column.minWidth! > sharedWidth) {
|
column.width = column.minWidth;
|
||||||
column.width = column.minWidth;
|
availableWidth -= column.width!;
|
||||||
availableWidth -= column.width!;
|
fieldCountWithoutWidth -= 1;
|
||||||
fieldCountWithoutWidth -= 1;
|
sharedWidth = availableWidth / fieldCountWithoutWidth;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Recalculate shared width after assigning minimum widths
|
// divide up the rest of the space
|
||||||
sharedWidth = availableWidth / fieldCountWithoutWidth;
|
for (const column of columns) {
|
||||||
|
|
||||||
// Second pass: Assign shared width to remaining columns
|
|
||||||
columns.forEach((column) => {
|
|
||||||
if (!column.width) {
|
if (!column.width) {
|
||||||
column.width = sharedWidth;
|
column.width = sharedWidth;
|
||||||
}
|
}
|
||||||
column.minWidth = COLUMN.MIN_WIDTH; // Ensure min-width is always set
|
column.minWidth = COLUMN.MIN_WIDTH;
|
||||||
});
|
}
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
@@ -980,6 +978,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
'--rdg-summary-border-color': theme.colors.border.medium,
|
'--rdg-summary-border-color': theme.colors.border.medium,
|
||||||
|
|
||||||
'.rdg-cell': {
|
'.rdg-cell': {
|
||||||
|
// Prevent collisions with custom cell components
|
||||||
|
zIndex: 2,
|
||||||
borderRight: 'none',
|
borderRight: 'none',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,20 +16,22 @@ var logger = log.New("datasource")
|
|||||||
|
|
||||||
// requiredURL contains the set of data sources that require a URL.
|
// requiredURL contains the set of data sources that require a URL.
|
||||||
var requiredURL = map[string]bool{
|
var requiredURL = map[string]bool{
|
||||||
datasources.DS_GRAPHITE: true,
|
datasources.DS_GRAPHITE: true,
|
||||||
datasources.DS_INFLUXDB: true,
|
datasources.DS_INFLUXDB: true,
|
||||||
datasources.DS_INFLUXDB_08: true,
|
datasources.DS_INFLUXDB_08: true,
|
||||||
datasources.DS_ES: true,
|
datasources.DS_ES: true,
|
||||||
datasources.DS_PROMETHEUS: true,
|
datasources.DS_PROMETHEUS: true,
|
||||||
datasources.DS_ALERTMANAGER: true,
|
datasources.DS_AMAZON_PROMETHEUS: true,
|
||||||
datasources.DS_JAEGER: true,
|
datasources.DS_AZURE_PROMETHEUS: true,
|
||||||
datasources.DS_LOKI: true,
|
datasources.DS_ALERTMANAGER: true,
|
||||||
datasources.DS_OPENTSDB: true,
|
datasources.DS_JAEGER: true,
|
||||||
datasources.DS_TEMPO: true,
|
datasources.DS_LOKI: true,
|
||||||
datasources.DS_ZIPKIN: true,
|
datasources.DS_OPENTSDB: true,
|
||||||
datasources.DS_MYSQL: true,
|
datasources.DS_TEMPO: true,
|
||||||
datasources.DS_POSTGRES: true,
|
datasources.DS_ZIPKIN: true,
|
||||||
datasources.DS_MSSQL: true,
|
datasources.DS_MYSQL: true,
|
||||||
|
datasources.DS_POSTGRES: true,
|
||||||
|
datasources.DS_MSSQL: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLValidationError represents an error from validating a data source URL.
|
// URLValidationError represents an error from validating a data source URL.
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ type FrontendSettingsDTO struct {
|
|||||||
AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting"`
|
AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting"`
|
||||||
|
|
||||||
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
|
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
|
||||||
|
PanelSeriesLimit int `json:"panelSeriesLimit"`
|
||||||
|
|
||||||
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
|
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
|
||||||
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
|
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
clientrest "k8s.io/client-go/rest"
|
clientrest "k8s.io/client-go/rest"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||||
@@ -618,7 +618,7 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
|
|||||||
|
|
||||||
cfg := setting.NewCfg()
|
cfg := setting.NewCfg()
|
||||||
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
|
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
|
||||||
folderv0alpha1.RESOURCEGROUP: {
|
folders.RESOURCEGROUP: {
|
||||||
DualWriterMode: tc.unifiedStorageMode,
|
DualWriterMode: tc.unifiedStorageMode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
|||||||
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
|
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
|
||||||
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
|
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
|
||||||
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
|
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
|
||||||
|
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
|
||||||
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
|
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
|
||||||
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
|
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
|
||||||
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
|
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
|
||||||
@@ -551,7 +552,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
|
|||||||
dsDTO.Database = ds.Database
|
dsDTO.Database = ds.Database
|
||||||
}
|
}
|
||||||
|
|
||||||
if ds.Type == datasources.DS_PROMETHEUS {
|
if ds.Type == datasources.DS_PROMETHEUS || ds.Type == datasources.DS_AMAZON_PROMETHEUS || ds.Type == datasources.DS_AZURE_PROMETHEUS {
|
||||||
// add unproxied server URL for link to Prometheus web UI
|
// add unproxied server URL for link to Prometheus web UI
|
||||||
ds.JsonData.Set("directUrl", ds.URL)
|
ds.JsonData.Set("directUrl", ds.URL)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-4
@@ -23,6 +23,25 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type URLPrefs struct {
|
||||||
|
Language string
|
||||||
|
Locale string
|
||||||
|
Theme string
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL prefs take precedence over any saved user preferences
|
||||||
|
func getURLPrefs(c *contextmodel.ReqContext) URLPrefs {
|
||||||
|
language := c.Query("lang")
|
||||||
|
theme := c.Query("theme")
|
||||||
|
locale := c.Query("locale")
|
||||||
|
|
||||||
|
return URLPrefs{
|
||||||
|
Language: language,
|
||||||
|
Locale: locale,
|
||||||
|
Theme: theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexViewData, error) {
|
func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexViewData, error) {
|
||||||
c, span := hs.injectSpan(c, "api.setIndexViewData")
|
c, span := hs.injectSpan(c, "api.setIndexViewData")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
@@ -50,10 +69,13 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
|||||||
// Locale is used for some number and date/time formatting, whereas language is used just for
|
// Locale is used for some number and date/time formatting, whereas language is used just for
|
||||||
// translating words in the interface
|
// translating words in the interface
|
||||||
acceptLangHeader := c.Req.Header.Get("Accept-Language")
|
acceptLangHeader := c.Req.Header.Get("Accept-Language")
|
||||||
locale := "en-US" // default to en-US formatting, but use the accept-lang header or user's preference
|
locale := "en-US" // default to en formatting, but use the accept-lang header or user's preference
|
||||||
language := "" // frontend will set the default language
|
language := "" // frontend will set the default language
|
||||||
|
urlPrefs := getURLPrefs(c)
|
||||||
|
|
||||||
if prefs.JSONData.Language != "" {
|
if urlPrefs.Language != "" {
|
||||||
|
language = urlPrefs.Language
|
||||||
|
} else if prefs.JSONData.Language != "" {
|
||||||
language = prefs.JSONData.Language
|
language = prefs.JSONData.Language
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +85,10 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagLocaleFormatPreference) {
|
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagLocaleFormatPreference) {
|
||||||
if prefs.JSONData.Locale != "" {
|
locale = "en" // default to "en", not "en-US", matching the locale code
|
||||||
|
if urlPrefs.Locale != "" {
|
||||||
|
locale = urlPrefs.Locale
|
||||||
|
} else if prefs.JSONData.Locale != "" {
|
||||||
locale = prefs.JSONData.Locale
|
locale = prefs.JSONData.Locale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +113,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
|||||||
weekStart = *prefs.WeekStart
|
weekStart = *prefs.WeekStart
|
||||||
}
|
}
|
||||||
|
|
||||||
theme := hs.getThemeForIndexData(prefs.Theme, c.Query("theme"))
|
theme := hs.getThemeForIndexData(prefs.Theme, urlPrefs.Theme)
|
||||||
assets, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License)
|
assets, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func (proxy *DataSourceProxy) validateRequest() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trailing validation below this point for routes that were not matched
|
// Trailing validation below this point for routes that were not matched
|
||||||
if proxy.ds.Type == datasources.DS_PROMETHEUS {
|
if proxy.ds.Type == datasources.DS_PROMETHEUS || proxy.ds.Type == datasources.DS_AMAZON_PROMETHEUS || proxy.ds.Type == datasources.DS_AZURE_PROMETHEUS {
|
||||||
if proxy.ctx.Req.Method == "DELETE" {
|
if proxy.ctx.Req.Method == "DELETE" {
|
||||||
return errors.New("non allow-listed DELETEs not allowed on proxied Prometheus datasource")
|
return errors.New("non allow-listed DELETEs not allowed on proxied Prometheus datasource")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
// +k8s:defaulter-gen=TypeMeta
|
// +k8s:defaulter-gen=TypeMeta
|
||||||
// +groupName=folder.grafana.app
|
// +groupName=folder.grafana.app
|
||||||
|
|
||||||
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
package v1 // import "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package v0alpha1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
GROUP = "folder.grafana.app"
|
GROUP = "folder.grafana.app"
|
||||||
VERSION = "v0alpha1"
|
VERSION = "v1"
|
||||||
RESOURCE = "folders"
|
RESOURCE = "folders"
|
||||||
APIVERSION = GROUP + "/" + VERSION
|
APIVERSION = GROUP + "/" + VERSION
|
||||||
RESOURCEGROUP = RESOURCE + "." + GROUP
|
RESOURCEGROUP = RESOURCE + "." + GROUP
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package v0alpha1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Code generated by deepcopy-gen. DO NOT EDIT.
|
// Code generated by deepcopy-gen. DO NOT EDIT.
|
||||||
|
|
||||||
package v0alpha1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Code generated by defaulter-gen. DO NOT EDIT.
|
// Code generated by defaulter-gen. DO NOT EDIT.
|
||||||
|
|
||||||
package v0alpha1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
+25
-25
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Code generated by openapi-gen. DO NOT EDIT.
|
// Code generated by openapi-gen. DO NOT EDIT.
|
||||||
|
|
||||||
package v0alpha1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
common "k8s.io/kube-openapi/pkg/common"
|
common "k8s.io/kube-openapi/pkg/common"
|
||||||
@@ -14,18 +14,18 @@ import (
|
|||||||
|
|
||||||
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||||
return map[string]common.OpenAPIDefinition{
|
return map[string]common.OpenAPIDefinition{
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.DescendantCounts": schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.DescendantCounts": schema_pkg_apis_folder_v1_DescendantCounts(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_Folder(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.Folder": schema_pkg_apis_folder_v1_Folder(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderAccessInfo": schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderAccessInfo": schema_pkg_apis_folder_v1_FolderAccessInfo(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_FolderInfo(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo": schema_pkg_apis_folder_v1_FolderInfo(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfoList": schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfoList": schema_pkg_apis_folder_v1_FolderInfoList(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_FolderList(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderList": schema_pkg_apis_folder_v1_FolderList(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats": schema_pkg_apis_folder_v0alpha1_ResourceStats(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats": schema_pkg_apis_folder_v1_ResourceStats(ref),
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref),
|
"github.com/grafana/grafana/pkg/apis/folder/v1.Spec": schema_pkg_apis_folder_v1_Spec(ref),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -52,7 +52,7 @@ func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallba
|
|||||||
Schema: &spec.Schema{
|
Schema: &spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
Default: map[string]interface{}{},
|
Default: map[string]interface{}{},
|
||||||
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats"),
|
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,11 +63,11 @@ func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallba
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dependencies: []string{
|
Dependencies: []string{
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats"},
|
"github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -96,18 +96,18 @@ func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common
|
|||||||
"spec": {
|
"spec": {
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
Default: map[string]interface{}{},
|
Default: map[string]interface{}{},
|
||||||
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec"),
|
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.Spec"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dependencies: []string{
|
Dependencies: []string{
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
"github.com/grafana/grafana/pkg/apis/folder/v1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -163,7 +163,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -214,7 +214,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_FolderInfoList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -256,7 +256,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback
|
|||||||
Schema: &spec.Schema{
|
Schema: &spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
Default: map[string]interface{}{},
|
Default: map[string]interface{}{},
|
||||||
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo"),
|
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -266,11 +266,11 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dependencies: []string{
|
Dependencies: []string{
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -303,7 +303,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) co
|
|||||||
Schema: &spec.Schema{
|
Schema: &spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
Default: map[string]interface{}{},
|
Default: map[string]interface{}{},
|
||||||
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder"),
|
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.Folder"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -313,11 +313,11 @@ func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) co
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Dependencies: []string{
|
Dependencies: []string{
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
"github.com/grafana/grafana/pkg/apis/folder/v1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_ResourceStats(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_ResourceStats(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
@@ -351,7 +351,7 @@ func schema_pkg_apis_folder_v0alpha1_ResourceStats(ref common.ReferenceCallback)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schema_pkg_apis_folder_v0alpha1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
func schema_pkg_apis_folder_v1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||||
return common.OpenAPIDefinition{
|
return common.OpenAPIDefinition{
|
||||||
Schema: spec.Schema{
|
Schema: spec.Schema{
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v0alpha1,DescendantCounts,Counts
|
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v1,DescendantCounts,Counts
|
||||||
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v0alpha1,FolderInfoList,Items
|
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v1,FolderInfoList,Items
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
|
||||||
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
|
|
||||||
authlib "github.com/grafana/authlib/types"
|
authlib "github.com/grafana/authlib/types"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ func (cmd *ConditionsCmd) NeedsVars() []string {
|
|||||||
|
|
||||||
// Execute runs the command and returns the results or an error if the command
|
// Execute runs the command and returns the results or an error if the command
|
||||||
// failed to execute.
|
// failed to execute.
|
||||||
func (cmd *ConditionsCmd) Execute(ctx context.Context, t time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (cmd *ConditionsCmd) Execute(ctx context.Context, t time.Time, vars mathexp.Vars, tracer tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
ctx, span := tracer.Start(ctx, "SSE.ExecuteClassicConditions")
|
ctx, span := tracer.Start(ctx, "SSE.ExecuteClassicConditions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
// isFiring and isNoData contains the outcome of ConditionsCmd, and is derived from the
|
// isFiring and isNoData contains the outcome of ConditionsCmd, and is derived from the
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ func TestConditionsCmd(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
res, err := tt.cmd.Execute(context.Background(), time.Now(), tt.vars, tracing.InitializeTracerForTest())
|
res, err := tt.cmd.Execute(context.Background(), time.Now(), tt.vars, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tt.expected(), res)
|
require.Equal(t, tt.expected(), res)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import (
|
|||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command is an interface for all expression commands.
|
// Command is an interface for all expression commands.
|
||||||
type Command interface {
|
type Command interface {
|
||||||
NeedsVars() []string
|
NeedsVars() []string
|
||||||
Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error)
|
Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, metrics *metrics.ExprMetrics) (mathexp.Results, error)
|
||||||
Type() string
|
Type() string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ func (gm *MathCommand) NeedsVars() []string {
|
|||||||
|
|
||||||
// Execute runs the command and returns the results or an error if the command
|
// Execute runs the command and returns the results or an error if the command
|
||||||
// failed to execute.
|
// failed to execute.
|
||||||
func (gm *MathCommand) Execute(ctx context.Context, _ time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (gm *MathCommand) Execute(ctx context.Context, _ time.Time, vars mathexp.Vars, tracer tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
_, span := tracer.Start(ctx, "SSE.ExecuteMath")
|
_, span := tracer.Start(ctx, "SSE.ExecuteMath")
|
||||||
span.SetAttributes(attribute.String("expression", gm.RawExpression))
|
span.SetAttributes(attribute.String("expression", gm.RawExpression))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
@@ -165,7 +167,7 @@ func (gr *ReduceCommand) NeedsVars() []string {
|
|||||||
|
|
||||||
// Execute runs the command and returns the results or an error if the command
|
// Execute runs the command and returns the results or an error if the command
|
||||||
// failed to execute.
|
// failed to execute.
|
||||||
func (gr *ReduceCommand) Execute(ctx context.Context, _ time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (gr *ReduceCommand) Execute(ctx context.Context, _ time.Time, vars mathexp.Vars, tracer tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
_, span := tracer.Start(ctx, "SSE.ExecuteReduce")
|
_, span := tracer.Start(ctx, "SSE.ExecuteReduce")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -295,7 +297,7 @@ func (gr *ResampleCommand) NeedsVars() []string {
|
|||||||
|
|
||||||
// Execute runs the command and returns the results or an error if the command
|
// Execute runs the command and returns the results or an error if the command
|
||||||
// failed to execute.
|
// failed to execute.
|
||||||
func (gr *ResampleCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (gr *ResampleCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
_, span := tracer.Start(ctx, "SSE.ExecuteResample")
|
_, span := tracer.Start(ctx, "SSE.ExecuteResample")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
newRes := mathexp.Results{}
|
newRes := mathexp.Results{}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func TestReduceExecute(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest())
|
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Len(t, execute.Values, len(numbers))
|
require.Len(t, execute.Values, len(numbers))
|
||||||
@@ -163,7 +163,7 @@ func TestReduceExecute(t *testing.T) {
|
|||||||
t.Run("drop all non numbers if mapper is DropNonNumber", func(t *testing.T) {
|
t.Run("drop all non numbers if mapper is DropNonNumber", func(t *testing.T) {
|
||||||
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.DropNonNumber{})
|
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.DropNonNumber{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest())
|
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, execute.Values, 2)
|
require.Len(t, execute.Values, 2)
|
||||||
})
|
})
|
||||||
@@ -171,7 +171,7 @@ func TestReduceExecute(t *testing.T) {
|
|||||||
t.Run("replace all non numbers if mapper is ReplaceNonNumberWithValue", func(t *testing.T) {
|
t.Run("replace all non numbers if mapper is ReplaceNonNumberWithValue", func(t *testing.T) {
|
||||||
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.ReplaceNonNumberWithValue{Value: 1})
|
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.ReplaceNonNumberWithValue{Value: 1})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest())
|
execute, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, execute.Values, len(numbers))
|
require.Len(t, execute.Values, len(numbers))
|
||||||
for _, value := range execute.Values[1 : len(numbers)-1] {
|
for _, value := range execute.Values[1 : len(numbers)-1] {
|
||||||
@@ -194,7 +194,7 @@ func TestReduceExecute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, nil)
|
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
results, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest())
|
results, err := cmd.Execute(context.Background(), time.Now(), vars, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Len(t, results.Values, 1)
|
require.Len(t, results.Values, 1)
|
||||||
@@ -253,7 +253,7 @@ func TestResampleCommand_Execute(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
||||||
varToReduce: mathexp.Results{Values: mathexp.Values{test.vals}},
|
varToReduce: mathexp.Results{Values: mathexp.Values{test.vals}},
|
||||||
}, tracing.InitializeTracerForTest())
|
}, tracing.InitializeTracerForTest(), nil)
|
||||||
if test.isError {
|
if test.isError {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -268,7 +268,7 @@ func TestResampleCommand_Execute(t *testing.T) {
|
|||||||
t.Run("should return empty result if input is nil Value", func(t *testing.T) {
|
t.Run("should return empty result if input is nil Value", func(t *testing.T) {
|
||||||
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
||||||
varToReduce: mathexp.Results{Values: mathexp.Values{nil}},
|
varToReduce: mathexp.Results{Values: mathexp.Values{nil}},
|
||||||
}, tracing.InitializeTracerForTest())
|
}, tracing.InitializeTracerForTest(), nil)
|
||||||
require.Empty(t, result.Values)
|
require.Empty(t, result.Values)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ func getResponseFrame(logger *log.ConcreteLogger, resp *backend.QueryDataRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAllFrameVectors(datasourceType string, frames data.Frames) bool {
|
func isAllFrameVectors(datasourceType string, frames data.Frames) bool {
|
||||||
if datasourceType != datasources.DS_PROMETHEUS {
|
if datasourceType != datasources.DS_PROMETHEUS && datasourceType != datasources.DS_AMAZON_PROMETHEUS && datasourceType != datasources.DS_AZURE_PROMETHEUS {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
allVector := false
|
allVector := false
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -21,7 +22,7 @@ func TestConvertDataFramesToResults(t *testing.T) {
|
|||||||
cfg: setting.NewCfg(),
|
cfg: setting.NewCfg(),
|
||||||
features: featuremgmt.WithFeatures(),
|
features: featuremgmt.WithFeatures(),
|
||||||
tracer: tracing.InitializeTracerForTest(),
|
tracer: tracing.InitializeTracerForTest(),
|
||||||
metrics: newMetrics(nil),
|
metrics: metrics.NewSSEMetrics(nil),
|
||||||
}
|
}
|
||||||
converter := &ResultConverter{Features: s.features, Tracer: s.tracer}
|
converter := &ResultConverter{Features: s.features, Tracer: s.tracer}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
@@ -64,7 +65,7 @@ func framesPassThroughService(t *testing.T, frames data.Frames) (data.Frames, er
|
|||||||
&datafakes.FakeCacheService{}, &datafakes.FakeDataSourceService{},
|
&datafakes.FakeCacheService{}, &datafakes.FakeDataSourceService{},
|
||||||
nil, pluginconfig.NewFakePluginRequestConfigProvider()),
|
nil, pluginconfig.NewFakePluginRequestConfigProvider()),
|
||||||
tracer: tracing.InitializeTracerForTest(),
|
tracer: tracing.InitializeTracerForTest(),
|
||||||
metrics: newMetrics(nil),
|
metrics: metrics.NewSSEMetrics(nil),
|
||||||
converter: &ResultConverter{
|
converter: &ResultConverter{
|
||||||
Features: features,
|
Features: features,
|
||||||
Tracer: tracing.InitializeTracerForTest(),
|
Tracer: tracing.InitializeTracerForTest(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ func (h *HysteresisCommand) NeedsVars() []string {
|
|||||||
return []string{h.ReferenceVar}
|
return []string{h.ReferenceVar}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, metrics *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
results := vars[h.ReferenceVar]
|
results := vars[h.ReferenceVar]
|
||||||
|
|
||||||
logger := logger.FromContext(ctx)
|
logger := logger.FromContext(ctx)
|
||||||
@@ -46,7 +47,7 @@ func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mat
|
|||||||
return mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil
|
return mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil
|
||||||
}
|
}
|
||||||
if len(h.LoadedDimensions) == 0 {
|
if len(h.LoadedDimensions) == 0 {
|
||||||
return h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
|
return h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
|
||||||
}
|
}
|
||||||
var loadedVals, unloadedVals mathexp.Values
|
var loadedVals, unloadedVals mathexp.Values
|
||||||
for _, value := range results.Values {
|
for _, value := range results.Values {
|
||||||
@@ -62,10 +63,10 @@ func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mat
|
|||||||
|
|
||||||
logger.Debug("Evaluating thresholds", "unloadingThresholdDimensions", len(loadedVals), "loadingThresholdDimensions", len(unloadedVals))
|
logger.Debug("Evaluating thresholds", "unloadingThresholdDimensions", len(loadedVals), "loadingThresholdDimensions", len(unloadedVals))
|
||||||
if len(loadedVals) == 0 { // if all values are unloaded
|
if len(loadedVals) == 0 { // if all values are unloaded
|
||||||
return h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
|
return h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
|
||||||
}
|
}
|
||||||
if len(unloadedVals) == 0 { // if all values are loaded
|
if len(unloadedVals) == 0 { // if all values are loaded
|
||||||
return h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
|
return h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -74,12 +75,12 @@ func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mat
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
vars[h.ReferenceVar] = mathexp.Results{Values: unloadedVals}
|
vars[h.ReferenceVar] = mathexp.Results{Values: unloadedVals}
|
||||||
loadingResults, err := h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
|
loadingResults, err := h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mathexp.Results{}, fmt.Errorf("failed to execute loading threshold: %w", err)
|
return mathexp.Results{}, fmt.Errorf("failed to execute loading threshold: %w", err)
|
||||||
}
|
}
|
||||||
vars[h.ReferenceVar] = mathexp.Results{Values: loadedVals}
|
vars[h.ReferenceVar] = mathexp.Results{Values: loadedVals}
|
||||||
unloadingResults, err := h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
|
unloadingResults, err := h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mathexp.Results{}, fmt.Errorf("failed to execute unloading threshold: %w", err)
|
return mathexp.Results{}, fmt.Errorf("failed to execute unloading threshold: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func TestHysteresisExecute(t *testing.T) {
|
|||||||
|
|
||||||
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
||||||
"A": mathexp.Results{Values: tc.input},
|
"A": mathexp.Results{Values: tc.input},
|
||||||
}, tracer)
|
}, tracer, nil)
|
||||||
if tc.expectedError != nil {
|
if tc.expectedError != nil {
|
||||||
require.ErrorIs(t, err, tc.expectedError)
|
require.ErrorIs(t, err, tc.expectedError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package expr
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
metricsSubSystem = "sse"
|
|
||||||
metricsNamespace = "grafana"
|
|
||||||
)
|
|
||||||
|
|
||||||
type metrics struct {
|
|
||||||
dsRequests *prometheus.CounterVec
|
|
||||||
|
|
||||||
// older metric
|
|
||||||
expressionsQuerySummary *prometheus.SummaryVec
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMetrics(reg prometheus.Registerer) *metrics {
|
|
||||||
m := &metrics{
|
|
||||||
dsRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: metricsNamespace,
|
|
||||||
Subsystem: metricsSubSystem,
|
|
||||||
Name: "ds_queries_total",
|
|
||||||
Help: "Number of datasource queries made via server side expression requests",
|
|
||||||
}, []string{"error", "dataplane", "datasource_type"}),
|
|
||||||
|
|
||||||
// older (No Namespace or Subsystem)
|
|
||||||
expressionsQuerySummary: prometheus.NewSummaryVec(
|
|
||||||
prometheus.SummaryOpts{
|
|
||||||
Name: "expressions_queries_duration_milliseconds",
|
|
||||||
Help: "Expressions query summary",
|
|
||||||
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
|
|
||||||
},
|
|
||||||
[]string{"status"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if reg != nil {
|
|
||||||
reg.MustRegister(
|
|
||||||
m.dsRequests,
|
|
||||||
m.expressionsQuerySummary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExprMetrics is a struct that contains all the metrics for an implementation of the expressions service
|
||||||
|
// shared between multiple versions of expressions service, which are delineated by the subsystem string
|
||||||
|
type ExprMetrics struct {
|
||||||
|
DSRequests *prometheus.CounterVec
|
||||||
|
ExpressionsQuerySummary *prometheus.SummaryVec
|
||||||
|
SqlCommandDuration *prometheus.HistogramVec
|
||||||
|
SqlCommandErrorCount *prometheus.CounterVec
|
||||||
|
SqlCommandCellCount *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExprMetrics(subsystem string) *ExprMetrics {
|
||||||
|
return &ExprMetrics{
|
||||||
|
DSRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "ds_queries_total",
|
||||||
|
Help: "Number of datasource queries made via server side expression requests",
|
||||||
|
}, []string{"error", "dataplane", "datasource_type"}),
|
||||||
|
|
||||||
|
ExpressionsQuerySummary: prometheus.NewSummaryVec(
|
||||||
|
prometheus.SummaryOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "expressions_queries_duration_milliseconds",
|
||||||
|
Help: "Expressions query summary",
|
||||||
|
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
|
||||||
|
},
|
||||||
|
[]string{"status"},
|
||||||
|
),
|
||||||
|
|
||||||
|
SqlCommandDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "sql_command_duration_seconds",
|
||||||
|
Help: "Duration of SQL command execution",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"status"}),
|
||||||
|
|
||||||
|
SqlCommandErrorCount: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "sql_command_errors_total",
|
||||||
|
Help: "Total number of SQL command execution errors",
|
||||||
|
}, []string{}),
|
||||||
|
|
||||||
|
SqlCommandCellCount: prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "sql_command_cell_count",
|
||||||
|
Help: "Distribution of the total number of cells in each SQL command execution",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(100, 2, 10),
|
||||||
|
},
|
||||||
|
[]string{"status"},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSEMetrics creates a new ExprMetrics struct for the ST implementation of the expressions service
|
||||||
|
func NewSSEMetrics(reg prometheus.Registerer) *ExprMetrics {
|
||||||
|
metricsSubSystem := "sse"
|
||||||
|
|
||||||
|
m := &ExprMetrics{
|
||||||
|
DSRequests: newExprMetrics(metricsSubSystem).DSRequests,
|
||||||
|
|
||||||
|
ExpressionsQuerySummary: newExprMetrics(metricsSubSystem).ExpressionsQuerySummary,
|
||||||
|
|
||||||
|
SqlCommandDuration: newExprMetrics(metricsSubSystem).SqlCommandDuration,
|
||||||
|
|
||||||
|
SqlCommandErrorCount: newExprMetrics(metricsSubSystem).SqlCommandErrorCount,
|
||||||
|
|
||||||
|
SqlCommandCellCount: newExprMetrics(metricsSubSystem).SqlCommandCellCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg != nil {
|
||||||
|
reg.MustRegister(
|
||||||
|
m.DSRequests,
|
||||||
|
m.ExpressionsQuerySummary,
|
||||||
|
m.SqlCommandDuration,
|
||||||
|
m.SqlCommandErrorCount,
|
||||||
|
m.SqlCommandCellCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQueryServiceExpressionsMetrics creates a new ExprMetrics struct for the query service implementation of the expressions service
|
||||||
|
func NewQueryServiceExpressionsMetrics(reg prometheus.Registerer) *ExprMetrics {
|
||||||
|
metricsSubSystem := "queryservice"
|
||||||
|
|
||||||
|
m := &ExprMetrics{
|
||||||
|
DSRequests: newExprMetrics(metricsSubSystem).DSRequests,
|
||||||
|
|
||||||
|
ExpressionsQuerySummary: newExprMetrics(metricsSubSystem).ExpressionsQuerySummary,
|
||||||
|
|
||||||
|
SqlCommandDuration: newExprMetrics(metricsSubSystem).SqlCommandDuration,
|
||||||
|
|
||||||
|
SqlCommandErrorCount: newExprMetrics(metricsSubSystem).SqlCommandErrorCount,
|
||||||
|
|
||||||
|
SqlCommandCellCount: newExprMetrics(metricsSubSystem).SqlCommandCellCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg != nil {
|
||||||
|
reg.MustRegister(
|
||||||
|
m.DSRequests,
|
||||||
|
m.ExpressionsQuerySummary,
|
||||||
|
m.SqlCommandDuration,
|
||||||
|
m.SqlCommandErrorCount,
|
||||||
|
m.SqlCommandCellCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestMetrics() *ExprMetrics {
|
||||||
|
return newExprMetrics("test")
|
||||||
|
}
|
||||||
+1
-1
@@ -86,7 +86,7 @@ func (m *MLNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s *
|
|||||||
}
|
}
|
||||||
logger.Debug("Data source queried", "responseType", responseType)
|
logger.Debug("Data source queried", "responseType", responseType)
|
||||||
useDataplane := strings.HasPrefix("dataplane-", responseType)
|
useDataplane := strings.HasPrefix("dataplane-", responseType)
|
||||||
s.metrics.dsRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), mlPluginID).Inc()
|
s.metrics.DSRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), mlPluginID).Inc()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Execute the command and provide callback function for sending a request via plugin API.
|
// Execute the command and provide callback function for sending a request via plugin API.
|
||||||
|
|||||||
+3
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/expr/ml"
|
"github.com/grafana/grafana/pkg/expr/ml"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
@@ -61,7 +62,7 @@ func TestMLNodeExecute(t *testing.T) {
|
|||||||
features: nil,
|
features: nil,
|
||||||
pluginsClient: pluginsClient,
|
pluginsClient: pluginsClient,
|
||||||
tracer: nil,
|
tracer: nil,
|
||||||
metrics: newMetrics(nil),
|
metrics: metrics.NewSSEMetrics(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdResponse := data.NewFrame("test",
|
cmdResponse := data.NewFrame("test",
|
||||||
@@ -198,7 +199,7 @@ func TestMLNodeExecute(t *testing.T) {
|
|||||||
features: nil,
|
features: nil,
|
||||||
pluginsClient: pluginsClient,
|
pluginsClient: pluginsClient,
|
||||||
tracer: nil,
|
tracer: nil,
|
||||||
metrics: newMetrics(nil),
|
metrics: metrics.NewSSEMetrics(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &ml.FakeCommand{
|
cmd := &ml.FakeCommand{
|
||||||
|
|||||||
+3
-3
@@ -103,7 +103,7 @@ func (gn *CMDNode) NeedsVars() []string {
|
|||||||
// other nodes they must have already been executed and their results must
|
// other nodes they must have already been executed and their results must
|
||||||
// already by in vars.
|
// already by in vars.
|
||||||
func (gn *CMDNode) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, s *Service) (mathexp.Results, error) {
|
func (gn *CMDNode) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, s *Service) (mathexp.Results, error) {
|
||||||
return gn.Command.Execute(ctx, now, vars, s.tracer)
|
return gn.Command.Execute(ctx, now, vars, s.tracer, s.metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles, sqlExpressionCellLimit int64) (*CMDNode, error) {
|
func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles, sqlExpressionCellLimit int64) (*CMDNode, error) {
|
||||||
@@ -320,7 +320,7 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars
|
|||||||
}
|
}
|
||||||
logger.Debug("Data source queried", "responseType", responseType)
|
logger.Debug("Data source queried", "responseType", responseType)
|
||||||
useDataplane := strings.HasPrefix(responseType, "dataplane-")
|
useDataplane := strings.HasPrefix(responseType, "dataplane-")
|
||||||
s.metrics.dsRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), firstNode.datasource.Type).Inc()
|
s.metrics.DSRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), firstNode.datasource.Type).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.dataService.QueryData(ctx, req)
|
resp, err := s.dataService.QueryData(ctx, req)
|
||||||
@@ -395,7 +395,7 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s
|
|||||||
}
|
}
|
||||||
logger.Debug("Data source queried", "responseType", responseType)
|
logger.Debug("Data source queried", "responseType", responseType)
|
||||||
useDataplane := strings.HasPrefix(responseType, "dataplane-")
|
useDataplane := strings.HasPrefix(responseType, "dataplane-")
|
||||||
s.metrics.dsRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), dn.datasource.Type).Inc()
|
s.metrics.DSRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), dn.datasource.Type).Inc()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
resp, err := s.dataService.QueryData(ctx, req)
|
resp, err := s.dataService.QueryData(ctx, req)
|
||||||
|
|||||||
+3
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
@@ -65,7 +66,7 @@ type Service struct {
|
|||||||
pluginsClient backend.CallResourceHandler
|
pluginsClient backend.CallResourceHandler
|
||||||
|
|
||||||
tracer tracing.Tracer
|
tracer tracing.Tracer
|
||||||
metrics *metrics
|
metrics *metrics.ExprMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
type pluginContextProvider interface {
|
type pluginContextProvider interface {
|
||||||
@@ -81,7 +82,7 @@ func ProvideService(cfg *setting.Cfg, pluginClient plugins.Client, pCtxProvider
|
|||||||
pCtxProvider: pCtxProvider,
|
pCtxProvider: pCtxProvider,
|
||||||
features: features,
|
features: features,
|
||||||
tracer: tracer,
|
tracer: tracer,
|
||||||
metrics: newMetrics(registerer),
|
metrics: metrics.NewSSEMetrics(registerer),
|
||||||
pluginsClient: pluginClient,
|
pluginsClient: pluginClient,
|
||||||
converter: &ResultConverter{
|
converter: &ResultConverter{
|
||||||
Features: features,
|
Features: features,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
@@ -249,7 +250,7 @@ func newMockQueryService(responses map[string]backend.DataResponse, queries []Qu
|
|||||||
pCtxProvider: pCtxProvider,
|
pCtxProvider: pCtxProvider,
|
||||||
features: featuremgmt.WithFeatures(),
|
features: featuremgmt.WithFeatures(),
|
||||||
tracer: tracing.InitializeTracerForTest(),
|
tracer: tracing.InitializeTracerForTest(),
|
||||||
metrics: newMetrics(nil),
|
metrics: metrics.NewSSEMetrics(nil),
|
||||||
converter: &ResultConverter{
|
converter: &ResultConverter{
|
||||||
Features: features,
|
Features: features,
|
||||||
Tracer: tracing.InitializeTracerForTest(),
|
Tracer: tracing.InitializeTracerForTest(),
|
||||||
|
|||||||
+20
-5
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/expr/sql"
|
"github.com/grafana/grafana/pkg/expr/sql"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
)
|
)
|
||||||
@@ -99,9 +100,22 @@ func (gr *SQLCommand) NeedsVars() []string {
|
|||||||
|
|
||||||
// Execute runs the command and returns the results or an error if the command
|
// Execute runs the command and returns the results or an error if the command
|
||||||
// failed to execute.
|
// failed to execute.
|
||||||
func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
|
func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, metrics *metrics.ExprMetrics) (mathExprResult mathexp.Results, resultError error) {
|
||||||
_, span := tracer.Start(ctx, "SSE.ExecuteSQL")
|
_, span := tracer.Start(ctx, "SSE.ExecuteSQL")
|
||||||
defer span.End()
|
start := time.Now()
|
||||||
|
tc := int64(0)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
span.End()
|
||||||
|
statusLabel := "ok"
|
||||||
|
duration := float64(time.Since(start).Nanoseconds()) / float64(time.Millisecond)
|
||||||
|
if resultError != nil {
|
||||||
|
statusLabel = "error"
|
||||||
|
metrics.SqlCommandErrorCount.WithLabelValues().Inc()
|
||||||
|
}
|
||||||
|
metrics.SqlCommandDuration.WithLabelValues(statusLabel).Observe(duration)
|
||||||
|
metrics.SqlCommandCellCount.WithLabelValues(statusLabel).Observe(float64(tc))
|
||||||
|
}()
|
||||||
|
|
||||||
allFrames := []*data.Frame{}
|
allFrames := []*data.Frame{}
|
||||||
for _, ref := range gr.varsToQuery {
|
for _, ref := range gr.varsToQuery {
|
||||||
@@ -114,14 +128,15 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V
|
|||||||
allFrames = append(allFrames, frames...)
|
allFrames = append(allFrames, frames...)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCells := totalCells(allFrames)
|
tc = totalCells(allFrames)
|
||||||
|
|
||||||
// limit of 0 or less means no limit (following convention)
|
// limit of 0 or less means no limit (following convention)
|
||||||
if gr.limit > 0 && totalCells > gr.limit {
|
if gr.limit > 0 && tc > gr.limit {
|
||||||
return mathexp.Results{},
|
return mathexp.Results{},
|
||||||
fmt.Errorf(
|
fmt.Errorf(
|
||||||
"SQL expression: total cell count across all input tables exceeds limit of %d. Total cells: %d",
|
"SQL expression: total cell count across all input tables exceeds limit of %d. Total cells: %d",
|
||||||
gr.limit,
|
gr.limit,
|
||||||
totalCells,
|
tc,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
@@ -134,7 +136,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = cmd.Execute(context.Background(), time.Now(), vars, &testTracer{})
|
_, err = cmd.Execute(context.Background(), time.Now(), vars, &testTracer{}, metrics.NewTestMetrics())
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
@@ -146,6 +148,28 @@ func TestSQLCommandCellLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSQLCommandMetrics(t *testing.T) {
|
||||||
|
// Create test metrics
|
||||||
|
m := metrics.NewTestMetrics()
|
||||||
|
|
||||||
|
// Create a command
|
||||||
|
cmd, err := NewSQLCommand("A", "someformat", "select * from foo", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Execute successful command
|
||||||
|
_, err = cmd.Execute(context.Background(), time.Now(), mathexp.Vars{}, &testTracer{}, m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify error count was not incremented
|
||||||
|
require.Equal(t, 0, testutil.CollectAndCount(m.SqlCommandErrorCount), "Expected error metric not to be recorded")
|
||||||
|
|
||||||
|
// Verify duration was recorded
|
||||||
|
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandDuration), "Expected duration metric to be recorded")
|
||||||
|
|
||||||
|
// Verify cell count was recorded
|
||||||
|
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCellCount), "Expected cell count metric to be recorded")
|
||||||
|
}
|
||||||
|
|
||||||
type testTracer struct {
|
type testTracer struct {
|
||||||
trace.Tracer
|
trace.Tracer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/expr/mathexp"
|
"github.com/grafana/grafana/pkg/expr/mathexp"
|
||||||
|
"github.com/grafana/grafana/pkg/expr/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@@ -174,7 +175,7 @@ func (tc *ThresholdCommand) NeedsVars() []string {
|
|||||||
return []string{tc.ReferenceVar}
|
return []string{tc.ReferenceVar}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tc *ThresholdCommand) Execute(_ context.Context, _ time.Time, vars mathexp.Vars, _ tracing.Tracer) (mathexp.Results, error) {
|
func (tc *ThresholdCommand) Execute(_ context.Context, _ time.Time, vars mathexp.Vars, _ tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
|
||||||
eval := func(maybeValue *float64) *float64 {
|
eval := func(maybeValue *float64) *float64 {
|
||||||
if maybeValue == nil {
|
if maybeValue == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func BenchmarkThreshold(b *testing.B) {
|
|||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = greater.Execute(ctx, timeNow, vars, trace)
|
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
b.Run("less than", func(b *testing.B) {
|
b.Run("less than", func(b *testing.B) {
|
||||||
@@ -43,7 +43,7 @@ func BenchmarkThreshold(b *testing.B) {
|
|||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = greater.Execute(ctx, timeNow, vars, trace)
|
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
b.Run("within range", func(b *testing.B) {
|
b.Run("within range", func(b *testing.B) {
|
||||||
@@ -53,7 +53,7 @@ func BenchmarkThreshold(b *testing.B) {
|
|||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = greater.Execute(ctx, timeNow, vars, trace)
|
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
b.Run("within range, no labels", func(b *testing.B) {
|
b.Run("within range, no labels", func(b *testing.B) {
|
||||||
@@ -72,7 +72,7 @@ func BenchmarkThreshold(b *testing.B) {
|
|||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = greater.Execute(ctx, timeNow, vars, trace)
|
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
b.Run("outside range", func(b *testing.B) {
|
b.Run("outside range", func(b *testing.B) {
|
||||||
@@ -82,7 +82,7 @@ func BenchmarkThreshold(b *testing.B) {
|
|||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = greater.Execute(ctx, timeNow, vars, trace)
|
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ func TestThresholdExecute(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
|
||||||
"A": newResults(input[name]),
|
"A": newResults(input[name]),
|
||||||
}, tracing.InitializeTracerForTest())
|
}, tracing.InitializeTracerForTest(), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, newResults(tc.expected[name]), result)
|
require.Equal(t, newResults(tc.expected[name]), result)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (s *Service) TransformData(ctx context.Context, now time.Time, req *Request
|
|||||||
respStatus = "failure"
|
respStatus = "failure"
|
||||||
}
|
}
|
||||||
duration := float64(time.Since(start).Nanoseconds()) / float64(time.Millisecond)
|
duration := float64(time.Since(start).Nanoseconds()) / float64(time.Millisecond)
|
||||||
s.metrics.expressionsQuerySummary.WithLabelValues(respStatus).Observe(duration)
|
s.metrics.ExpressionsQuerySummary.WithLabelValues(respStatus).Observe(duration)
|
||||||
|
|
||||||
span.End()
|
span.End()
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -46,12 +46,26 @@ func (s *Service) detectPrometheusVariants(ctx context.Context) (map[string]int6
|
|||||||
s.log.Error("Failed to read all Prometheus data sources", "error", err)
|
s.log.Error("Failed to read all Prometheus data sources", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
dsAmazonProm := &datasources.GetDataSourcesByTypeQuery{Type: "grafana-amazonprometheus-datasource"}
|
||||||
|
dataSourcesAmazonProm, err := s.datasources.GetDataSourcesByType(ctx, dsAmazonProm)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("Failed to read all Amazon Prometheus data sources", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dsAzureProm := &datasources.GetDataSourcesByTypeQuery{Type: "grafana-azureprometheus-datasource"}
|
||||||
|
dataSourcesAzureProm, err := s.datasources.GetDataSourcesByType(ctx, dsAzureProm)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("Failed to read all Azure Prometheus data sources", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allPromDataSources := append(append(dataSources, dataSourcesAmazonProm...), dataSourcesAzureProm...)
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.SetLimit(10)
|
g.SetLimit(10)
|
||||||
flavors := sync.Map{}
|
flavors := sync.Map{}
|
||||||
|
|
||||||
for _, ds := range dataSources {
|
for _, ds := range allPromDataSources {
|
||||||
ds := ds
|
ds := ds
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
variant, err := s.detectPrometheusVariant(ctx, ds)
|
variant, err := s.detectPrometheusVariant(ctx, ds)
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ func TestDetectPrometheusVariant(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(cortex.Close)
|
t.Cleanup(cortex.Close)
|
||||||
|
|
||||||
|
// Amazon Prometheus is Cortex-like
|
||||||
|
amazonPrometheus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(amazonPrometheus.Close)
|
||||||
|
|
||||||
|
// Azure Prometheus is Cortex-like
|
||||||
|
azurePrometheus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(azurePrometheus.Close)
|
||||||
|
|
||||||
sqlStore := dbtest.NewFakeDB()
|
sqlStore := dbtest.NewFakeDB()
|
||||||
statsService := statstest.NewFakeService()
|
statsService := statstest.NewFakeService()
|
||||||
s := createService(
|
s := createService(
|
||||||
@@ -80,6 +92,26 @@ func TestDetectPrometheusVariant(t *testing.T) {
|
|||||||
Access: "proxy",
|
Access: "proxy",
|
||||||
URL: cortex.URL,
|
URL: cortex.URL,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: 5,
|
||||||
|
UID: "amazon-prometheus",
|
||||||
|
OrgID: 1,
|
||||||
|
Version: 1,
|
||||||
|
Name: "Amazon Prometheus",
|
||||||
|
Type: "prometheus",
|
||||||
|
Access: "proxy",
|
||||||
|
URL: amazonPrometheus.URL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 6,
|
||||||
|
UID: "azure-prometheus",
|
||||||
|
OrgID: 1,
|
||||||
|
Version: 1,
|
||||||
|
Name: "Azure Prometheus",
|
||||||
|
Type: "prometheus",
|
||||||
|
Access: "proxy",
|
||||||
|
URL: azurePrometheus.URL,
|
||||||
|
},
|
||||||
}}),
|
}}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,5 +120,5 @@ func TestDetectPrometheusVariant(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, int64(2), flavors["mimir"])
|
assert.Equal(t, int64(2), flavors["mimir"])
|
||||||
assert.Equal(t, int64(1), flavors["vanilla"])
|
assert.Equal(t, int64(1), flavors["vanilla"])
|
||||||
assert.Equal(t, int64(1), flavors["cortex-like"])
|
assert.Equal(t, int64(3), flavors["cortex-like"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
|
||||||
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLargeDashboardSupport(t *testing.T) {
|
func TestLargeDashboardSupport(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestLargeDashboardSupport(t *testing.T) {
|
|||||||
f, err := os.ReadFile(devdash)
|
f, err := os.ReadFile(devdash)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dash := &dashboardv1alpha1.Dashboard{
|
dash := &dashv1.Dashboard{
|
||||||
ObjectMeta: v1.ObjectMeta{
|
ObjectMeta: v1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "test",
|
Namespace: "test",
|
||||||
@@ -38,7 +38,7 @@ func TestLargeDashboardSupport(t *testing.T) {
|
|||||||
|
|
||||||
scheme := runtime.NewScheme()
|
scheme := runtime.NewScheme()
|
||||||
|
|
||||||
err = dashboardv1alpha1.AddToScheme(scheme)
|
err = dashv1.AddToScheme(scheme)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
|
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
|
||||||
@@ -56,7 +56,7 @@ func TestLargeDashboardSupport(t *testing.T) {
|
|||||||
}`, string(small))
|
}`, string(small))
|
||||||
|
|
||||||
// Now make it big again
|
// Now make it big again
|
||||||
rehydratedDash := &dashboardv1alpha1.Dashboard{
|
rehydratedDash := &dashv1.Dashboard{
|
||||||
ObjectMeta: v1.ObjectMeta{
|
ObjectMeta: v1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "test",
|
Namespace: "test",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
authlib "github.com/grafana/authlib/types"
|
authlib "github.com/grafana/authlib/types"
|
||||||
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||||
@@ -326,7 +326,7 @@ func (a *dashboardSqlAccess) migrateFolders(ctx context.Context, orgId int64, op
|
|||||||
// Now send each dashboard
|
// Now send each dashboard
|
||||||
for i := 1; rows.Next(); i++ {
|
for i := 1; rows.Next(); i++ {
|
||||||
dash := rows.row.Dash
|
dash := rows.row.Dash
|
||||||
dash.APIVersion = "folder.grafana.app/v0alpha1"
|
dash.APIVersion = "folder.grafana.app/v1"
|
||||||
dash.Kind = "Folder"
|
dash.Kind = "Folder"
|
||||||
dash.SetNamespace(opts.Namespace)
|
dash.SetNamespace(opts.Namespace)
|
||||||
dash.SetResourceVersion("") // it will be filled in by the backend
|
dash.SetResourceVersion("") // it will be filled in by the backend
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
dash := &dashboard.Dashboard{
|
dash := &dashboard.Dashboard{
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
APIVersion: "dashboard.grafana.app/v1alpha1",
|
APIVersion: dashboard.APIVERSION,
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test-dash",
|
Name: "test-dash",
|
||||||
@@ -170,7 +170,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
|
|||||||
&dashboards.Dashboard{
|
&dashboards.Dashboard{
|
||||||
ID: 1234,
|
ID: 1234,
|
||||||
Version: 2,
|
Version: 2,
|
||||||
APIVersion: "dashboard.grafana.app/v1alpha1",
|
APIVersion: dashboard.APIVERSION,
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
|
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||||
@@ -92,7 +92,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
|||||||
switch req.Options.Key.Resource {
|
switch req.Options.Key.Resource {
|
||||||
case dashboard.DASHBOARD_RESOURCE:
|
case dashboard.DASHBOARD_RESOURCE:
|
||||||
queryType = searchstore.TypeDashboard
|
queryType = searchstore.TypeDashboard
|
||||||
case folderv0alpha1.RESOURCE:
|
case folders.RESOURCE:
|
||||||
queryType = searchstore.TypeFolder
|
queryType = searchstore.TypeFolder
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("bad type request")
|
return nil, fmt.Errorf("bad type request")
|
||||||
@@ -104,7 +104,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
|||||||
|
|
||||||
if len(req.Federated) == 1 &&
|
if len(req.Federated) == 1 &&
|
||||||
((req.Federated[0].Resource == dashboard.DASHBOARD_RESOURCE && queryType == searchstore.TypeFolder) ||
|
((req.Federated[0].Resource == dashboard.DASHBOARD_RESOURCE && queryType == searchstore.TypeFolder) ||
|
||||||
(req.Federated[0].Resource == folderv0alpha1.RESOURCE && queryType == searchstore.TypeDashboard)) {
|
(req.Federated[0].Resource == folders.RESOURCE && queryType == searchstore.TypeDashboard)) {
|
||||||
queryType = "" // makes the legacy store search across both
|
queryType = "" // makes the legacy store search across both
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,8 +336,8 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
|
|||||||
if item.IsFolder {
|
if item.IsFolder {
|
||||||
return &resource.ResourceKey{
|
return &resource.ResourceKey{
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Group: folderv0alpha1.GROUP,
|
Group: folders.GROUP,
|
||||||
Resource: folderv0alpha1.RESOURCE,
|
Resource: folders.RESOURCE,
|
||||||
Name: item.UID,
|
Name: item.UID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.Reso
|
|||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case dashboard.GROUP:
|
case dashboard.GROUP:
|
||||||
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, false)
|
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, false)
|
||||||
case folderv0alpha1.GROUP:
|
case folders.GROUP:
|
||||||
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
|
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid group")
|
return nil, fmt.Errorf("invalid group")
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
@@ -25,7 +25,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "should skip non-create/update operations",
|
name: "should skip non-create/update operations",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{
|
Spec: common.Unstructured{
|
||||||
Object: map[string]interface{}{
|
Object: map[string]interface{}{
|
||||||
"id": float64(123),
|
"id": float64(123),
|
||||||
@@ -37,7 +37,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "v0 should extract id and set as label",
|
name: "v0 should extract id and set as label",
|
||||||
inputObj: &v0alpha1.Dashboard{
|
inputObj: &dashv0.Dashboard{
|
||||||
Spec: common.Unstructured{
|
Spec: common.Unstructured{
|
||||||
Object: map[string]interface{}{
|
Object: map[string]interface{}{
|
||||||
"id": float64(123),
|
"id": float64(123),
|
||||||
@@ -49,7 +49,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "v1 should migrate dashboard to the latest version, if possible, and set as label",
|
name: "v1 should migrate dashboard to the latest version, if possible, and set as label",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{
|
Spec: common.Unstructured{
|
||||||
Object: map[string]interface{}{
|
Object: map[string]interface{}{
|
||||||
"id": float64(456),
|
"id": float64(456),
|
||||||
@@ -63,7 +63,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "v1 should not error mutation hook if migration fails",
|
name: "v1 should not error mutation hook if migration fails",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{
|
Spec: common.Unstructured{
|
||||||
Object: map[string]interface{}{
|
Object: map[string]interface{}{
|
||||||
"id": float64(456),
|
"id": float64(456),
|
||||||
@@ -100,10 +100,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
|
|||||||
require.Equal(t, tt.expectedID, meta.GetDeprecatedInternalID()) //nolint:staticcheck
|
require.Equal(t, tt.expectedID, meta.GetDeprecatedInternalID()) //nolint:staticcheck
|
||||||
|
|
||||||
switch v := tt.inputObj.(type) {
|
switch v := tt.inputObj.(type) {
|
||||||
case *v0alpha1.Dashboard:
|
case *dashv0.Dashboard:
|
||||||
_, exists := v.Spec.Object["id"]
|
_, exists := v.Spec.Object["id"]
|
||||||
require.False(t, exists, "id should be removed from spec")
|
require.False(t, exists, "id should be removed from spec")
|
||||||
case *v1alpha1.Dashboard:
|
case *dashv1.Dashboard:
|
||||||
_, exists := v.Spec.Object["id"]
|
_, exists := v.Spec.Object["id"]
|
||||||
require.False(t, exists, "id should be removed from spec")
|
require.False(t, exists, "id should be removed from spec")
|
||||||
schemaVersion, ok := v.Spec.Object["schemaVersion"].(int)
|
schemaVersion, ok := v.Spec.Object["schemaVersion"].(int)
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import (
|
|||||||
|
|
||||||
claims "github.com/grafana/authlib/types"
|
claims "github.com/grafana/authlib/types"
|
||||||
internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
|
internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion"
|
"github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
@@ -48,7 +48,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||||
|
|
||||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||||
)
|
)
|
||||||
@@ -113,7 +113,7 @@ func RegisterAPIService(
|
|||||||
dbp := legacysql.NewDatabaseProvider(sql)
|
dbp := legacysql.NewDatabaseProvider(sql)
|
||||||
namespacer := request.GetNamespaceMapper(cfg)
|
namespacer := request.GetNamespaceMapper(cfg)
|
||||||
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||||
folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folderv0alpha1.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter)
|
folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folders.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter)
|
||||||
builder := &DashboardsAPIBuilder{
|
builder := &DashboardsAPIBuilder{
|
||||||
log: log.New("grafana-apiserver.dashboards"),
|
log: log.New("grafana-apiserver.dashboards"),
|
||||||
|
|
||||||
@@ -146,28 +146,28 @@ func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
|||||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||||
// If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
|
// If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
|
||||||
return []schema.GroupVersion{
|
return []schema.GroupVersion{
|
||||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv2.DashboardResourceInfo.GroupVersion(),
|
||||||
v0alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv0.DashboardResourceInfo.GroupVersion(),
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv1.DashboardResourceInfo.GroupVersion(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []schema.GroupVersion{
|
return []schema.GroupVersion{
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv1.DashboardResourceInfo.GroupVersion(),
|
||||||
v0alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv0.DashboardResourceInfo.GroupVersion(),
|
||||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv2.DashboardResourceInfo.GroupVersion(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||||
b.scheme = scheme
|
b.scheme = scheme
|
||||||
if err := v0alpha1.AddToScheme(scheme); err != nil {
|
if err := dashv0.AddToScheme(scheme); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
if err := dashv1.AddToScheme(scheme); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := v2alpha1.AddToScheme(scheme); err != nil {
|
if err := dashv2.AddToScheme(scheme); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,13 +370,13 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
|
|||||||
|
|
||||||
// Extract properties based on the object's type
|
// Extract properties based on the object's type
|
||||||
switch d := obj.(type) {
|
switch d := obj.(type) {
|
||||||
case *v0alpha1.Dashboard:
|
case *dashv0.Dashboard:
|
||||||
title = d.Spec.GetNestedString(dashboardSpecTitle)
|
title = d.Spec.GetNestedString(dashboardSpecTitle)
|
||||||
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
|
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
|
||||||
case *v1alpha1.Dashboard:
|
case *dashv1.Dashboard:
|
||||||
title = d.Spec.GetNestedString(dashboardSpecTitle)
|
title = d.Spec.GetNestedString(dashboardSpecTitle)
|
||||||
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
|
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
|
||||||
case *v2alpha1.Dashboard:
|
case *dashv2.Dashboard:
|
||||||
title = d.Spec.Title
|
title = d.Spec.Title
|
||||||
refresh = d.Spec.TimeSettings.AutoRefresh
|
refresh = d.Spec.TimeSettings.AutoRefresh
|
||||||
default:
|
default:
|
||||||
@@ -401,15 +401,15 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
|||||||
largeObjects = NewDashboardLargeObjectSupport(opts.Scheme, opts.StorageOpts.BlobThresholdBytes)
|
largeObjects = NewDashboardLargeObjectSupport(opts.Scheme, opts.StorageOpts.BlobThresholdBytes)
|
||||||
storageOpts.LargeObjectSupport = largeObjects
|
storageOpts.LargeObjectSupport = largeObjects
|
||||||
}
|
}
|
||||||
opts.StorageOptsRegister(v0alpha1.DashboardResourceInfo.GroupResource(), storageOpts)
|
opts.StorageOptsRegister(dashv0.DashboardResourceInfo.GroupResource(), storageOpts)
|
||||||
|
|
||||||
// v0alpha1
|
// v0alpha1
|
||||||
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
||||||
v0alpha1.DashboardResourceInfo,
|
dashv0.DashboardResourceInfo,
|
||||||
&v0alpha1.LibraryPanelResourceInfo,
|
&dashv0.LibraryPanelResourceInfo,
|
||||||
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
||||||
dto := &v0alpha1.DashboardWithAccessInfo{}
|
dto := &dashv0.DashboardWithAccessInfo{}
|
||||||
dash, ok := obj.(*v0alpha1.Dashboard)
|
dash, ok := obj.(*dashv0.Dashboard)
|
||||||
if ok {
|
if ok {
|
||||||
dto.Dashboard = *dash
|
dto.Dashboard = *dash
|
||||||
}
|
}
|
||||||
@@ -423,11 +423,11 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
|||||||
|
|
||||||
// v1alpha1
|
// v1alpha1
|
||||||
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
||||||
v1alpha1.DashboardResourceInfo,
|
dashv1.DashboardResourceInfo,
|
||||||
nil, // do not register library panel
|
nil, // do not register library panel
|
||||||
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
||||||
dto := &v1alpha1.DashboardWithAccessInfo{}
|
dto := &dashv1.DashboardWithAccessInfo{}
|
||||||
dash, ok := obj.(*v1alpha1.Dashboard)
|
dash, ok := obj.(*dashv1.Dashboard)
|
||||||
if ok {
|
if ok {
|
||||||
dto.Dashboard = *dash
|
dto.Dashboard = *dash
|
||||||
}
|
}
|
||||||
@@ -441,11 +441,11 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
|||||||
|
|
||||||
// v2alpha1
|
// v2alpha1
|
||||||
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
|
||||||
v2alpha1.DashboardResourceInfo,
|
dashv2.DashboardResourceInfo,
|
||||||
nil, // do not register library panel
|
nil, // do not register library panel
|
||||||
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
|
||||||
dto := &v2alpha1.DashboardWithAccessInfo{}
|
dto := &dashv2.DashboardWithAccessInfo{}
|
||||||
dash, ok := obj.(*v2alpha1.Dashboard)
|
dash, ok := obj.(*dashv2.Dashboard)
|
||||||
if ok {
|
if ok {
|
||||||
dto.Dashboard = *dash
|
dto.Dashboard = *dash
|
||||||
}
|
}
|
||||||
@@ -515,9 +515,9 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
|||||||
|
|
||||||
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||||
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||||
defs := v0alpha1.GetOpenAPIDefinitions(ref)
|
defs := dashv0.GetOpenAPIDefinitions(ref)
|
||||||
maps.Copy(defs, v1alpha1.GetOpenAPIDefinitions(ref))
|
maps.Copy(defs, dashv1.GetOpenAPIDefinitions(ref))
|
||||||
maps.Copy(defs, v2alpha1.GetOpenAPIDefinitions(ref))
|
maps.Copy(defs, dashv2.GetOpenAPIDefinitions(ref))
|
||||||
return defs
|
return defs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||||
if gv.Version != v0alpha1.VERSION {
|
if gv.Version != dashv0.VERSION {
|
||||||
return nil // Only show the custom routes for v0
|
return nil // Only show the custom routes for v0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
|
||||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -24,7 +24,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
zeroInt64 := int64(0)
|
zeroInt64 := int64(0)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
inputObj *v1alpha1.Dashboard
|
inputObj *dashv1.Dashboard
|
||||||
deletionOptions metav1.DeleteOptions
|
deletionOptions metav1.DeleteOptions
|
||||||
dashboardResponse *dashboards.DashboardProvisioning
|
dashboardResponse *dashboards.DashboardProvisioning
|
||||||
dashboardErrorResponse error
|
dashboardErrorResponse error
|
||||||
@@ -33,7 +33,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "should return an error if data is found",
|
name: "should return an error if data is found",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{},
|
Spec: common.Unstructured{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Dashboard",
|
Kind: "Dashboard",
|
||||||
@@ -52,7 +52,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should return an error if unable to check",
|
name: "should return an error if unable to check",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{},
|
Spec: common.Unstructured{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Dashboard",
|
Kind: "Dashboard",
|
||||||
@@ -71,7 +71,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should be okay if error is provisioned dashboard not found",
|
name: "should be okay if error is provisioned dashboard not found",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{},
|
Spec: common.Unstructured{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Dashboard",
|
Kind: "Dashboard",
|
||||||
@@ -90,7 +90,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Should still run the check for delete if grace period is not 0",
|
name: "Should still run the check for delete if grace period is not 0",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{},
|
Spec: common.Unstructured{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Dashboard",
|
Kind: "Dashboard",
|
||||||
@@ -109,7 +109,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should not run the check for delete if grace period is set to 0",
|
name: "should not run the check for delete if grace period is set to 0",
|
||||||
inputObj: &v1alpha1.Dashboard{
|
inputObj: &dashv1.Dashboard{
|
||||||
Spec: common.Unstructured{},
|
Spec: common.Unstructured{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Dashboard",
|
Kind: "Dashboard",
|
||||||
@@ -137,10 +137,10 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
tt.inputObj,
|
tt.inputObj,
|
||||||
nil,
|
nil,
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersionKind(),
|
dashv1.DashboardResourceInfo.GroupVersionKind(),
|
||||||
"stacks-123",
|
"stacks-123",
|
||||||
tt.inputObj.Name,
|
tt.inputObj.Name,
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersionResource(),
|
dashv1.DashboardResourceInfo.GroupVersionResource(),
|
||||||
"",
|
"",
|
||||||
admission.Operation("DELETE"),
|
admission.Operation("DELETE"),
|
||||||
&tt.deletionOptions,
|
&tt.deletionOptions,
|
||||||
@@ -173,9 +173,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
|
|||||||
name: "should return v1alpha1 by default",
|
name: "should return v1alpha1 by default",
|
||||||
enabledFeatures: []string{},
|
enabledFeatures: []string{},
|
||||||
expected: []schema.GroupVersion{
|
expected: []schema.GroupVersion{
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv1.DashboardResourceInfo.GroupVersion(),
|
||||||
v0alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv0.DashboardResourceInfo.GroupVersion(),
|
||||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv2.DashboardResourceInfo.GroupVersion(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,9 +184,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
|
|||||||
featuremgmt.FlagKubernetesDashboards,
|
featuremgmt.FlagKubernetesDashboards,
|
||||||
},
|
},
|
||||||
expected: []schema.GroupVersion{
|
expected: []schema.GroupVersion{
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv1.DashboardResourceInfo.GroupVersion(),
|
||||||
v0alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv0.DashboardResourceInfo.GroupVersion(),
|
||||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv2.DashboardResourceInfo.GroupVersion(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195,9 +195,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
|
|||||||
featuremgmt.FlagDashboardNewLayouts,
|
featuremgmt.FlagDashboardNewLayouts,
|
||||||
},
|
},
|
||||||
expected: []schema.GroupVersion{
|
expected: []schema.GroupVersion{
|
||||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv2.DashboardResourceInfo.GroupVersion(),
|
||||||
v0alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv0.DashboardResourceInfo.GroupVersion(),
|
||||||
v1alpha1.DashboardResourceInfo.GroupVersion(),
|
dashv1.DashboardResourceInfo.GroupVersion(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
|
|
||||||
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
@@ -265,7 +265,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE)
|
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE)
|
||||||
// Currently a search query is across folders and dashboards
|
// Currently a search query is across folders and dashboards
|
||||||
if err == nil {
|
if err == nil {
|
||||||
federate, err = asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
|
federate, err = asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||||
}
|
}
|
||||||
case 1:
|
case 1:
|
||||||
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
|
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
|
||||||
@@ -473,7 +473,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
// only folders the user has access to will be returned here
|
// only folders the user has access to will be returned here
|
||||||
folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
|
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sharedDashboards, err
|
return sharedDashboards, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
folderv0aplha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -261,7 +261,7 @@ func TestMultiTenantAuthorizer(t *testing.T) {
|
|||||||
authz := newMultiTenantAuthorizer(tt.input.client)
|
authz := newMultiTenantAuthorizer(tt.input.client)
|
||||||
authorized, _, err := authz.Authorize(
|
authorized, _, err := authz.Authorize(
|
||||||
types.WithAuthInfo(context.Background(), tt.input.info),
|
types.WithAuthInfo(context.Background(), tt.input.info),
|
||||||
authorizer.AttributesRecord{User: tt.input.info, Verb: tt.input.verb, APIGroup: folderv0aplha1.GROUP, Resource: "folders", ResourceRequest: true, Name: "123", Namespace: "stacks-1"},
|
authorizer.AttributesRecord{User: tt.input.info, Verb: tt.input.verb, APIGroup: folders.GROUP, Resource: "folders", ResourceRequest: true, Name: "123", Namespace: "stacks-1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if tt.expeted.err {
|
if tt.expeted.err {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
claims "github.com/grafana/authlib/types"
|
claims "github.com/grafana/authlib/types"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
@@ -40,20 +40,20 @@ func LegacyCreateCommandToUnstructured(cmd *folder.CreateFolderCommand) (*unstru
|
|||||||
return obj, nil
|
return obj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LegacyFolderToUnstructured(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) {
|
func LegacyFolderToUnstructured(v *folder.Folder, namespacer request.NamespaceMapper) (*folders.Folder, error) {
|
||||||
return convertToK8sResource(v, namespacer)
|
return convertToK8sResource(v, namespacer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) {
|
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*folders.Folder, error) {
|
||||||
f := &v0alpha1.Folder{
|
f := &folders.Folder{
|
||||||
TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(),
|
TypeMeta: folders.FolderResourceInfo.TypeMeta(),
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: v.UID,
|
Name: v.UID,
|
||||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
||||||
CreationTimestamp: metav1.NewTime(v.Created),
|
CreationTimestamp: metav1.NewTime(v.Created),
|
||||||
Namespace: namespacer(v.OrgID),
|
Namespace: namespacer(v.OrgID),
|
||||||
},
|
},
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: v.Title,
|
Title: v.Title,
|
||||||
Description: v.Description,
|
Description: v.Description,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
claims "github.com/grafana/authlib/types"
|
claims "github.com/grafana/authlib/types"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||||
@@ -91,7 +91,7 @@ func (s *folderStorage) Create(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
p, ok := obj.(*v0alpha1.Folder)
|
p, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("expected folder?")
|
return nil, fmt.Errorf("expected folder?")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
@@ -50,7 +50,7 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) {
|
|||||||
store: &fakeStorage{},
|
store: &fakeStorage{},
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
obj := &v0alpha1.Folder{}
|
obj := &folders.Folder{}
|
||||||
|
|
||||||
ctx := request.WithNamespace(context.Background(), "org-2")
|
ctx := request.WithNamespace(context.Background(), "org-2")
|
||||||
ctx = identity.WithRequester(ctx, &user.SignedInUser{
|
ctx = identity.WithRequester(ctx, &user.SignedInUser{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/api/apierrors"
|
"github.com/grafana/grafana/pkg/api/apierrors"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"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/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -116,7 +116,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
list := &v0alpha1.FolderList{}
|
list := &folders.FolderList{}
|
||||||
for _, v := range hits {
|
for _, v := range hits {
|
||||||
r, err := convertToK8sResource(v, s.namespacer)
|
r, err := convertToK8sResource(v, s.namespacer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,7 +178,7 @@ func (s *legacyStorage) Create(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
p, ok := obj.(*v0alpha1.Folder)
|
p, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("expected folder?")
|
return nil, fmt.Errorf("expected folder?")
|
||||||
}
|
}
|
||||||
@@ -248,11 +248,11 @@ func (s *legacyStorage) Update(ctx context.Context,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return oldObj, created, err
|
return oldObj, created, err
|
||||||
}
|
}
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, created, fmt.Errorf("expected folder after update")
|
return nil, created, fmt.Errorf("expected folder after update")
|
||||||
}
|
}
|
||||||
old, ok := oldObj.(*v0alpha1.Folder)
|
old, ok := oldObj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, created, fmt.Errorf("expected old object to be a folder also")
|
return nil, created, fmt.Errorf("expected old object to be a folder also")
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
p, ok := v.(*v0alpha1.Folder)
|
p, ok := v.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return v, false, fmt.Errorf("expected a folder response from Get")
|
return v, false, fmt.Errorf("expected a folder response from Get")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
@@ -57,7 +57,7 @@ func TestLegacyStorageList(t *testing.T) {
|
|||||||
|
|
||||||
uidsReturnedByList := []string{}
|
uidsReturnedByList := []string{}
|
||||||
for _, obj := range list {
|
for _, obj := range list {
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folderv1.Folder)
|
||||||
require.Equal(t, true, ok)
|
require.Equal(t, true, ok)
|
||||||
uidsReturnedByList = append(uidsReturnedByList, f.Name)
|
uidsReturnedByList = append(uidsReturnedByList, f.Name)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
|
|||||||
result, err := storage.List(ctx, options)
|
result, err := storage.List(ctx, options)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
list, ok := result.(*v0alpha1.FolderList)
|
list, ok := result.(*folderv1.FolderList)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
token, err := base64.StdEncoding.DecodeString(list.Continue)
|
token, err := base64.StdEncoding.DecodeString(list.Continue)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -113,7 +113,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
|
|||||||
|
|
||||||
result, err := storage.List(ctx, options)
|
result, err := storage.List(ctx, options)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
list, ok := result.(*v0alpha1.FolderList)
|
list, ok := result.(*folderv1.FolderList)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
token, err := base64.StdEncoding.DecodeString(list.Continue)
|
token, err := base64.StdEncoding.DecodeString(list.Continue)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -152,7 +152,7 @@ func TestLegacyStorage_List_LabelSelector(t *testing.T) {
|
|||||||
require.False(t, folderService.LastQuery.WithFullpath)
|
require.False(t, folderService.LastQuery.WithFullpath)
|
||||||
require.False(t, folderService.LastQuery.WithFullpathUIDs)
|
require.False(t, folderService.LastQuery.WithFullpathUIDs)
|
||||||
|
|
||||||
list, ok := result.(*v0alpha1.FolderList)
|
list, ok := result.(*folderv1.FolderList)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Len(t, list.Items, 1)
|
require.Len(t, list.Items, 1)
|
||||||
})
|
})
|
||||||
@@ -181,7 +181,7 @@ func TestLegacyStorage_List_LabelSelector(t *testing.T) {
|
|||||||
require.True(t, folderService.LastQuery.WithFullpath)
|
require.True(t, folderService.LastQuery.WithFullpath)
|
||||||
require.True(t, folderService.LastQuery.WithFullpathUIDs)
|
require.True(t, folderService.LastQuery.WithFullpathUIDs)
|
||||||
|
|
||||||
list, ok := result.(*v0alpha1.FolderList)
|
list, ok := result.(*folderv1.FolderList)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Len(t, list.Items, 1)
|
require.Len(t, list.Items, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
authtypes "github.com/grafana/authlib/types"
|
authtypes "github.com/grafana/authlib/types"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
@@ -37,7 +37,7 @@ import (
|
|||||||
var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
|
var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
|
||||||
var _ builder.APIGroupValidation = (*FolderAPIBuilder)(nil)
|
var _ builder.APIGroupValidation = (*FolderAPIBuilder)(nil)
|
||||||
|
|
||||||
var resourceInfo = v0alpha1.FolderResourceInfo
|
var resourceInfo = folders.FolderResourceInfo
|
||||||
|
|
||||||
var errNoUser = errors.New("valid user is required")
|
var errNoUser = errors.New("valid user is required")
|
||||||
var errNoResource = errors.New("resource name is required")
|
var errNoResource = errors.New("resource name is required")
|
||||||
@@ -67,13 +67,6 @@ func RegisterAPIService(cfg *setting.Cfg,
|
|||||||
registerer prometheus.Registerer,
|
registerer prometheus.Registerer,
|
||||||
unified resource.ResourceClient,
|
unified resource.ResourceClient,
|
||||||
) *FolderAPIBuilder {
|
) *FolderAPIBuilder {
|
||||||
if !featuremgmt.AnyEnabled(features,
|
|
||||||
featuremgmt.FlagKubernetesClientDashboardsFolders,
|
|
||||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
||||||
featuremgmt.FlagProvisioning) {
|
|
||||||
return nil // skip registration unless opting into Kubernetes folders or unless we want to customize registration when testing
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := &FolderAPIBuilder{
|
builder := &FolderAPIBuilder{
|
||||||
gv: resourceInfo.GroupVersion(),
|
gv: resourceInfo.GroupVersion(),
|
||||||
features: features,
|
features: features,
|
||||||
@@ -103,11 +96,11 @@ func (b *FolderAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
|||||||
|
|
||||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||||
scheme.AddKnownTypes(gv,
|
scheme.AddKnownTypes(gv,
|
||||||
&v0alpha1.Folder{},
|
&folders.Folder{},
|
||||||
&v0alpha1.FolderList{},
|
&folders.FolderList{},
|
||||||
&v0alpha1.FolderInfoList{},
|
&folders.FolderInfoList{},
|
||||||
&v0alpha1.DescendantCounts{},
|
&folders.DescendantCounts{},
|
||||||
&v0alpha1.FolderAccessInfo{},
|
&folders.FolderAccessInfo{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +135,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
storage[resourceInfo.StoragePath()] = store
|
storage[resourceInfo.StoragePath()] = store
|
||||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
|
apiGroupInfo.VersionedResourcesStorageMap[folders.VERSION] = storage
|
||||||
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
|
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -187,13 +180,13 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
|||||||
storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher}
|
storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher}
|
||||||
storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc}
|
storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc}
|
||||||
|
|
||||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
|
apiGroupInfo.VersionedResourcesStorageMap[folders.VERSION] = storage
|
||||||
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
|
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||||
return v0alpha1.GetOpenAPIDefinitions
|
return folders.GetOpenAPIDefinitions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FolderAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
func (b *FolderAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||||
@@ -222,9 +215,9 @@ func (b *FolderAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, _
|
|||||||
verb := a.GetOperation()
|
verb := a.GetOperation()
|
||||||
if verb == admission.Create || verb == admission.Update {
|
if verb == admission.Create || verb == admission.Update {
|
||||||
obj := a.GetObject()
|
obj := a.GetObject()
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
return fmt.Errorf("obj is not folders.Folder")
|
||||||
}
|
}
|
||||||
f.Spec.Title = strings.Trim(f.Spec.Title, "")
|
f.Spec.Title = strings.Trim(f.Spec.Title, "")
|
||||||
return nil
|
return nil
|
||||||
@@ -239,9 +232,9 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
|
|||||||
return nil // This is normal for sub-resource
|
return nil // This is normal for sub-resource
|
||||||
}
|
}
|
||||||
|
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
return fmt.Errorf("obj is not folders.Folder")
|
||||||
}
|
}
|
||||||
verb := a.GetOperation()
|
verb := a.GetOperation()
|
||||||
|
|
||||||
@@ -262,7 +255,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FolderAPIBuilder) validateOnDelete(ctx context.Context, f *v0alpha1.Folder) error {
|
func (b *FolderAPIBuilder) validateOnDelete(ctx context.Context, f *folders.Folder) error {
|
||||||
resp, err := b.searcher.GetStats(ctx, &resource.ResourceStatsRequest{Namespace: f.Namespace, Folder: f.Name})
|
resp, err := b.searcher.GetStats(ctx, &resource.ResourceStatsRequest{Namespace: f.Namespace, Folder: f.Name})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -292,9 +285,9 @@ func (b *FolderAPIBuilder) validateOnCreate(ctx context.Context, id string, obj
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
return fmt.Errorf("obj is not folders.Folder")
|
||||||
}
|
}
|
||||||
if f.Spec.Title == "" {
|
if f.Spec.Title == "" {
|
||||||
return dashboards.ErrFolderTitleEmpty
|
return dashboards.ErrFolderTitleEmpty
|
||||||
@@ -342,14 +335,14 @@ func (b *FolderAPIBuilder) checkFolderMaxDepth(ctx context.Context, obj runtime.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *FolderAPIBuilder) validateOnUpdate(ctx context.Context, obj, old runtime.Object) error {
|
func (b *FolderAPIBuilder) validateOnUpdate(ctx context.Context, obj, old runtime.Object) error {
|
||||||
f, ok := obj.(*v0alpha1.Folder)
|
f, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
return fmt.Errorf("obj is not folders.Folder")
|
||||||
}
|
}
|
||||||
|
|
||||||
fOld, ok := old.(*v0alpha1.Folder)
|
fOld, ok := old.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
return fmt.Errorf("obj is not folders.Folder")
|
||||||
}
|
}
|
||||||
var newParent = getParent(obj)
|
var newParent = getParent(obj)
|
||||||
if newParent != getParent(fOld) {
|
if newParent != getParent(fOld) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"k8s.io/apiserver/pkg/admission"
|
"k8s.io/apiserver/pkg/admission"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
|
|
||||||
func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||||
type input struct {
|
type input struct {
|
||||||
obj *v0alpha1.Folder
|
obj *folders.Folder
|
||||||
annotations map[string]string
|
annotations map[string]string
|
||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
@@ -30,15 +30,15 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
initialMaxDepth := folderValidationRules.maxDepth
|
initialMaxDepth := folderValidationRules.maxDepth
|
||||||
folderValidationRules.maxDepth = 2
|
folderValidationRules.maxDepth = 2
|
||||||
defer func() { folderValidationRules.maxDepth = initialMaxDepth }()
|
defer func() { folderValidationRules.maxDepth = initialMaxDepth }()
|
||||||
deepFolder := &v0alpha1.Folder{
|
deepFolder := &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
deepFolder.Name = "valid-parent"
|
deepFolder.Name = "valid-parent"
|
||||||
deepFolder.Annotations = map[string]string{"grafana.app/folder": "valid-grandparent"}
|
deepFolder.Annotations = map[string]string{"grafana.app/folder": "valid-grandparent"}
|
||||||
parentFolder := &v0alpha1.Folder{
|
parentFolder := &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo-grandparent",
|
Title: "foo-grandparent",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "should return error when name is invalid",
|
name: "should return error when name is invalid",
|
||||||
input: input{
|
input: input{
|
||||||
obj: &v0alpha1.Folder{
|
obj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -65,8 +65,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "should return no error if every validation passes",
|
name: "should return no error if every validation passes",
|
||||||
input: input{
|
input: input{
|
||||||
obj: &v0alpha1.Folder{
|
obj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -76,8 +76,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "should not allow creating a folder in a tree that is too deep",
|
name: "should not allow creating a folder in a tree that is too deep",
|
||||||
input: input{
|
input: input{
|
||||||
obj: &v0alpha1.Folder{
|
obj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -97,8 +97,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "should return error when title is empty",
|
name: "should return error when title is empty",
|
||||||
input: input{
|
input: input{
|
||||||
obj: &v0alpha1.Folder{
|
obj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "",
|
Title: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,8 +110,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
name: "should return error if folder is a parent of itself",
|
name: "should return error if folder is a parent of itself",
|
||||||
input: input{
|
input: input{
|
||||||
annotations: map[string]string{utils.AnnoKeyFolder: "myself"},
|
annotations: map[string]string{utils.AnnoKeyFolder: "myself"},
|
||||||
obj: &v0alpha1.Folder{
|
obj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "title",
|
Title: "title",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -145,10 +145,10 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
tt.input.obj,
|
tt.input.obj,
|
||||||
nil,
|
nil,
|
||||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
folders.SchemeGroupVersion.WithKind("folder"),
|
||||||
"stacks-123",
|
"stacks-123",
|
||||||
tt.input.name,
|
tt.input.name,
|
||||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
folders.SchemeGroupVersion.WithResource("folders"),
|
||||||
"",
|
"",
|
||||||
"CREATE",
|
"CREATE",
|
||||||
nil,
|
nil,
|
||||||
@@ -188,8 +188,8 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
|||||||
us := storageMock{m, s}
|
us := storageMock{m, s}
|
||||||
sm := searcherMock{Mock: m}
|
sm := searcherMock{Mock: m}
|
||||||
|
|
||||||
obj := &v0alpha1.Folder{
|
obj := &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -221,10 +221,10 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
obj,
|
obj,
|
||||||
nil,
|
nil,
|
||||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
folders.SchemeGroupVersion.WithKind("folder"),
|
||||||
obj.Namespace,
|
obj.Namespace,
|
||||||
obj.Name,
|
obj.Name,
|
||||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
folders.SchemeGroupVersion.WithResource("folders"),
|
||||||
"",
|
"",
|
||||||
"DELETE",
|
"DELETE",
|
||||||
nil,
|
nil,
|
||||||
@@ -243,7 +243,7 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
||||||
var circularObj = &v0alpha1.Folder{
|
var circularObj = &folders.Folder{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Namespace: "stacks-123",
|
Namespace: "stacks-123",
|
||||||
Name: "new-parent",
|
Name: "new-parent",
|
||||||
@@ -253,15 +253,15 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
updatedObj *v0alpha1.Folder
|
updatedObj *folders.Folder
|
||||||
expected *v0alpha1.Folder
|
expected *folders.Folder
|
||||||
setupFn func(*mock.Mock)
|
setupFn func(*mock.Mock)
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "should allow updating a folder spec",
|
name: "should allow updating a folder spec",
|
||||||
updatedObj: &v0alpha1.Folder{
|
updatedObj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "different title",
|
Title: "different title",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -270,8 +270,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: &v0alpha1.Folder{
|
expected: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "different title",
|
Title: "different title",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -283,8 +283,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "updated title should not be empty",
|
name: "updated title should not be empty",
|
||||||
updatedObj: &v0alpha1.Folder{
|
updatedObj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "",
|
Title: "",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -297,8 +297,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should allow moving to a valid parent",
|
name: "should allow moving to a valid parent",
|
||||||
updatedObj: &v0alpha1.Folder{
|
updatedObj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -309,14 +309,14 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
setupFn: func(m *mock.Mock) {
|
setupFn: func(m *mock.Mock) {
|
||||||
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
|
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
|
||||||
&v0alpha1.Folder{},
|
&folders.Folder{},
|
||||||
nil).Once()
|
nil).Once()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should not allow moving to a k6 folder",
|
name: "should not allow moving to a k6 folder",
|
||||||
updatedObj: &v0alpha1.Folder{
|
updatedObj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -327,15 +327,15 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
setupFn: func(m *mock.Mock) {
|
setupFn: func(m *mock.Mock) {
|
||||||
m.On("Get", mock.Anything, accesscontrol.K6FolderUID, mock.Anything).Return(
|
m.On("Get", mock.Anything, accesscontrol.K6FolderUID, mock.Anything).Return(
|
||||||
&v0alpha1.Folder{},
|
&folders.Folder{},
|
||||||
nil).Once()
|
nil).Once()
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should not allow moving to a folder that is too deep",
|
name: "should not allow moving to a folder that is too deep",
|
||||||
updatedObj: &v0alpha1.Folder{
|
updatedObj: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -358,8 +358,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
us := storageMock{m, s}
|
us := storageMock{m, s}
|
||||||
sm := searcherMock{Mock: m}
|
sm := searcherMock{Mock: m}
|
||||||
|
|
||||||
obj := &v0alpha1.Folder{
|
obj := &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -386,10 +386,10 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
tt.updatedObj,
|
tt.updatedObj,
|
||||||
obj,
|
obj,
|
||||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
folders.SchemeGroupVersion.WithKind("folder"),
|
||||||
tt.updatedObj.Namespace,
|
tt.updatedObj.Namespace,
|
||||||
tt.updatedObj.Name,
|
tt.updatedObj.Name,
|
||||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
folders.SchemeGroupVersion.WithResource("folders"),
|
||||||
"",
|
"",
|
||||||
"UPDATE",
|
"UPDATE",
|
||||||
nil,
|
nil,
|
||||||
@@ -410,14 +410,14 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
|||||||
func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input *v0alpha1.Folder
|
input *folders.Folder
|
||||||
expected *v0alpha1.Folder
|
expected *folders.Folder
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "should trim a title",
|
name: "should trim a title",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: " foo ",
|
Title: " foo ",
|
||||||
},
|
},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
@@ -427,8 +427,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
|||||||
Name: "valid-name",
|
Name: "valid-name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: &v0alpha1.Folder{
|
expected: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
@@ -441,8 +441,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should return error if title doesnt exist",
|
name: "should return error if title doesnt exist",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{},
|
Spec: folders.Spec{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Folder",
|
Kind: "Folder",
|
||||||
},
|
},
|
||||||
@@ -454,7 +454,7 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should return error if spec doesnt exist",
|
name: "should return error if spec doesnt exist",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Folder",
|
Kind: "Folder",
|
||||||
},
|
},
|
||||||
@@ -482,10 +482,10 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
tt.input,
|
tt.input,
|
||||||
nil,
|
nil,
|
||||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
folders.SchemeGroupVersion.WithKind("folder"),
|
||||||
"stacks-123",
|
"stacks-123",
|
||||||
tt.input.Name,
|
tt.input.Name,
|
||||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
folders.SchemeGroupVersion.WithResource("folders"),
|
||||||
"",
|
"",
|
||||||
"CREATE",
|
"CREATE",
|
||||||
nil,
|
nil,
|
||||||
@@ -503,8 +503,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
||||||
existingObj := &v0alpha1.Folder{
|
existingObj := &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "some title",
|
Title: "some title",
|
||||||
},
|
},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
@@ -516,14 +516,14 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
|||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input *v0alpha1.Folder
|
input *folders.Folder
|
||||||
expected *v0alpha1.Folder
|
expected *folders.Folder
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "should trim a title",
|
name: "should trim a title",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: " foo ",
|
Title: " foo ",
|
||||||
},
|
},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
@@ -533,8 +533,8 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
|||||||
Name: "valid-name",
|
Name: "valid-name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: &v0alpha1.Folder{
|
expected: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
},
|
},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
@@ -547,8 +547,8 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should return error if title doesnt exist",
|
name: "should return error if title doesnt exist",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
Spec: v0alpha1.Spec{},
|
Spec: folders.Spec{},
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Folder",
|
Kind: "Folder",
|
||||||
},
|
},
|
||||||
@@ -560,7 +560,7 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should return error if spec doesnt exist",
|
name: "should return error if spec doesnt exist",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
TypeMeta: metav1.TypeMeta{
|
TypeMeta: metav1.TypeMeta{
|
||||||
Kind: "Folder",
|
Kind: "Folder",
|
||||||
},
|
},
|
||||||
@@ -588,10 +588,10 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
|
|||||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||||
tt.input,
|
tt.input,
|
||||||
existingObj,
|
existingObj,
|
||||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
folders.SchemeGroupVersion.WithKind("folder"),
|
||||||
"stacks-123",
|
"stacks-123",
|
||||||
tt.input.Name,
|
tt.input.Name,
|
||||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
folders.SchemeGroupVersion.WithResource("folders"),
|
||||||
"",
|
"",
|
||||||
"UPDATE",
|
"UPDATE",
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||||
"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"
|
||||||
@@ -22,7 +22,7 @@ var _ = rest.Connecter(&subAccessREST{})
|
|||||||
var _ = rest.StorageMetadata(&subAccessREST{})
|
var _ = rest.StorageMetadata(&subAccessREST{})
|
||||||
|
|
||||||
func (r *subAccessREST) New() runtime.Object {
|
func (r *subAccessREST) New() runtime.Object {
|
||||||
return &v0alpha1.FolderAccessInfo{}
|
return &folders.FolderAccessInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subAccessREST) Destroy() {
|
func (r *subAccessREST) Destroy() {
|
||||||
@@ -37,7 +37,7 @@ func (r *subAccessREST) ProducesMIMETypes(verb string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *subAccessREST) ProducesObject(verb string) interface{} {
|
func (r *subAccessREST) ProducesObject(verb string) interface{} {
|
||||||
return &v0alpha1.FolderAccessInfo{}
|
return &folders.FolderAccessInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) {
|
func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||||
@@ -69,7 +69,7 @@ func (r *subAccessREST) Connect(ctx context.Context, name string, opts runtime.O
|
|||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
access := &v0alpha1.FolderAccessInfo{}
|
access := &folders.FolderAccessInfo{}
|
||||||
access.CanEdit, _ = guardian.CanEdit()
|
access.CanEdit, _ = guardian.CanEdit()
|
||||||
access.CanSave, _ = guardian.CanSave()
|
access.CanSave, _ = guardian.CanSave()
|
||||||
access.CanAdmin, _ = guardian.CanAdmin()
|
access.CanAdmin, _ = guardian.CanAdmin()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||||
)
|
)
|
||||||
@@ -22,7 +22,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *subCountREST) New() runtime.Object {
|
func (r *subCountREST) New() runtime.Object {
|
||||||
return &v0alpha1.DescendantCounts{}
|
return &folders.DescendantCounts{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subCountREST) Destroy() {
|
func (r *subCountREST) Destroy() {
|
||||||
@@ -37,7 +37,7 @@ func (r *subCountREST) ProducesMIMETypes(verb string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *subCountREST) ProducesObject(verb string) interface{} {
|
func (r *subCountREST) ProducesObject(verb string) interface{} {
|
||||||
return &v0alpha1.DescendantCounts{}
|
return &folders.DescendantCounts{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subCountREST) NewConnectOptions() (runtime.Object, bool, string) {
|
func (r *subCountREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||||
@@ -60,11 +60,11 @@ func (r *subCountREST) Connect(ctx context.Context, name string, opts runtime.Ob
|
|||||||
responder.Error(err)
|
responder.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rsp := &v0alpha1.DescendantCounts{
|
rsp := &folders.DescendantCounts{
|
||||||
Counts: make([]v0alpha1.ResourceStats, len(stats.Stats)),
|
Counts: make([]folders.ResourceStats, len(stats.Stats)),
|
||||||
}
|
}
|
||||||
for i, v := range stats.Stats {
|
for i, v := range stats.Stats {
|
||||||
rsp.Counts[i] = v0alpha1.ResourceStats{
|
rsp.Counts[i] = folders.ResourceStats{
|
||||||
Group: v.Group,
|
Group: v.Group,
|
||||||
Resource: v.Resource,
|
Resource: v.Resource,
|
||||||
Count: v.Count,
|
Count: v.Count,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -14,46 +14,46 @@ import (
|
|||||||
func TestSubParent(t *testing.T) {
|
func TestSubParent(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input *v0alpha1.Folder
|
input *folders.Folder
|
||||||
expected *v0alpha1.FolderInfoList
|
expected *folders.FolderInfoList
|
||||||
setuFn func(*mock.Mock)
|
setuFn func(*mock.Mock)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no parents",
|
name: "no parents",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Annotations: map[string]string{},
|
Annotations: map[string]string{},
|
||||||
},
|
},
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "some tittle",
|
Title: "some tittle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: &v0alpha1.FolderInfoList{Items: []v0alpha1.FolderInfo{{Name: "test", Title: "some tittle"}}},
|
expected: &folders.FolderInfoList{Items: []folders.FolderInfo{{Name: "test", Title: "some tittle"}}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "has a parent",
|
name: "has a parent",
|
||||||
input: &v0alpha1.Folder{
|
input: &folders.Folder{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Annotations: map[string]string{"grafana.app/folder": "parent-test"},
|
Annotations: map[string]string{"grafana.app/folder": "parent-test"},
|
||||||
},
|
},
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "some tittle",
|
Title: "some tittle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setuFn: func(m *mock.Mock) {
|
setuFn: func(m *mock.Mock) {
|
||||||
m.On("Get", context.TODO(), "parent-test", &metav1.GetOptions{}).Return(&v0alpha1.Folder{
|
m.On("Get", context.TODO(), "parent-test", &metav1.GetOptions{}).Return(&folders.Folder{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "parent-test",
|
Name: "parent-test",
|
||||||
Annotations: map[string]string{},
|
Annotations: map[string]string{},
|
||||||
},
|
},
|
||||||
Spec: v0alpha1.Spec{
|
Spec: folders.Spec{
|
||||||
Title: "some other tittle",
|
Title: "some other tittle",
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
},
|
},
|
||||||
expected: &v0alpha1.FolderInfoList{Items: []v0alpha1.FolderInfo{
|
expected: &folders.FolderInfoList{Items: []folders.FolderInfo{
|
||||||
{Name: "test", Title: "some tittle", Parent: "parent-test"},
|
{Name: "test", Title: "some tittle", Parent: "parent-test"},
|
||||||
{Name: "parent-test", Title: "some other tittle"}},
|
{Name: "parent-test", Title: "some other tittle"}},
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type subParentsREST struct {
|
type subParentsREST struct {
|
||||||
@@ -21,7 +21,7 @@ var _ = rest.Connecter(&subParentsREST{})
|
|||||||
var _ = rest.StorageMetadata(&subParentsREST{})
|
var _ = rest.StorageMetadata(&subParentsREST{})
|
||||||
|
|
||||||
func (r *subParentsREST) New() runtime.Object {
|
func (r *subParentsREST) New() runtime.Object {
|
||||||
return &v0alpha1.FolderInfoList{}
|
return &folders.FolderInfoList{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subParentsREST) Destroy() {
|
func (r *subParentsREST) Destroy() {
|
||||||
@@ -36,7 +36,7 @@ func (r *subParentsREST) ProducesMIMETypes(verb string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *subParentsREST) ProducesObject(verb string) interface{} {
|
func (r *subParentsREST) ProducesObject(verb string) interface{} {
|
||||||
return &v0alpha1.FolderInfoList{}
|
return &folders.FolderInfoList{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) {
|
func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||||
@@ -48,7 +48,7 @@ func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
folder, ok := obj.(*v0alpha1.Folder)
|
folder, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("expecting folder, found: %T", folder)
|
return nil, fmt.Errorf("expecting folder, found: %T", folder)
|
||||||
}
|
}
|
||||||
@@ -61,13 +61,13 @@ func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *v0alpha1.FolderInfoList {
|
func (r *subParentsREST) parents(ctx context.Context, folder *folders.Folder) *folders.FolderInfoList {
|
||||||
info := &v0alpha1.FolderInfoList{
|
info := &folders.FolderInfoList{
|
||||||
Items: []v0alpha1.FolderInfo{},
|
Items: []folders.FolderInfo{},
|
||||||
}
|
}
|
||||||
for folder != nil {
|
for folder != nil {
|
||||||
parent := getParent(folder)
|
parent := getParent(folder)
|
||||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
info.Items = append(info.Items, folders.FolderInfo{
|
||||||
Name: folder.Name,
|
Name: folder.Name,
|
||||||
Title: folder.Spec.Title,
|
Title: folder.Spec.Title,
|
||||||
Description: folder.Spec.Description,
|
Description: folder.Spec.Description,
|
||||||
@@ -79,7 +79,7 @@ func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *
|
|||||||
|
|
||||||
obj, err := r.getter.Get(ctx, parent, &metav1.GetOptions{})
|
obj, err := r.getter.Get(ctx, parent, &metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
info.Items = append(info.Items, folders.FolderInfo{
|
||||||
Name: parent,
|
Name: parent,
|
||||||
Detached: true,
|
Detached: true,
|
||||||
Description: err.Error(),
|
Description: err.Error(),
|
||||||
@@ -87,9 +87,9 @@ func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
parentFolder, ok := obj.(*v0alpha1.Folder)
|
parentFolder, ok := obj.(*folders.Folder)
|
||||||
if !ok {
|
if !ok {
|
||||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
info.Items = append(info.Items, folders.FolderInfo{
|
||||||
Name: parent,
|
Name: parent,
|
||||||
Detached: true,
|
Detached: true,
|
||||||
Description: fmt.Sprintf("expected folder, found: %T", obj),
|
Description: fmt.Sprintf("expected folder, found: %T", obj),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana-app-sdk/logging"
|
"github.com/grafana/grafana-app-sdk/logging"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
|
||||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
|
|||||||
}
|
}
|
||||||
namespace := rw.Config().Namespace
|
namespace := rw.Config().Namespace
|
||||||
|
|
||||||
progress.SetMessage(ctx, "loading legacy folders")
|
progress.SetMessage(ctx, "loading folders from SQL")
|
||||||
reader := NewLegacyFolderReader(w.legacyMigrator, rw.Config().Name, namespace)
|
reader := NewLegacyFolderReader(w.legacyMigrator, rw.Config().Name, namespace)
|
||||||
if err = reader.Read(ctx, w.legacyMigrator, rw.Config().Name, namespace); err != nil {
|
if err = reader.Read(ctx, w.legacyMigrator, rw.Config().Name, namespace); err != nil {
|
||||||
return fmt.Errorf("error loading folder tree: %w", err)
|
return fmt.Errorf("error loading folder tree: %w", err)
|
||||||
@@ -154,7 +154,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
folders := resources.NewFolderManager(rw, folderClient, resources.NewEmptyFolderTree())
|
folders := resources.NewFolderManager(rw, folderClient, resources.NewEmptyFolderTree())
|
||||||
progress.SetMessage(ctx, "exporting legacy folders")
|
progress.SetMessage(ctx, "exporting folders from SQL")
|
||||||
err = folders.EnsureFolderTreeExists(ctx, "", "", reader.Tree(), func(folder resources.Folder, created bool, err error) error {
|
err = folders.EnsureFolderTreeExists(ctx, "", "", reader.Tree(), func(folder resources.Folder, created bool, err error) error {
|
||||||
result := jobs.JobResourceResult{
|
result := jobs.JobResourceResult{
|
||||||
Action: repository.FileActionCreated,
|
Action: repository.FileActionCreated,
|
||||||
@@ -176,7 +176,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
|
|||||||
return fmt.Errorf("error exporting legacy folders: %w", err)
|
return fmt.Errorf("error exporting legacy folders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.SetMessage(ctx, "exporting legacy resources")
|
progress.SetMessage(ctx, "exporting resources from SQL")
|
||||||
resourceManager := resources.NewResourcesManager(rw, folders, parser, clients, userInfo)
|
resourceManager := resources.NewResourcesManager(rw, folders, parser, clients, userInfo)
|
||||||
for _, kind := range resources.SupportedProvisioningResources {
|
for _, kind := range resources.SupportedProvisioningResources {
|
||||||
if kind == resources.FolderResource {
|
if kind == resources.FolderResource {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package pullrequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana-app-sdk/logging"
|
||||||
|
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
|
||||||
|
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
type changeInfo struct {
|
||||||
|
GrafanaBaseURL string
|
||||||
|
|
||||||
|
// Files we tried to read
|
||||||
|
Changes []fileChangeInfo
|
||||||
|
|
||||||
|
// More files changed than we processed
|
||||||
|
SkippedFiles int
|
||||||
|
|
||||||
|
// Requested image render, but it is not available
|
||||||
|
MissingImageRenderer bool
|
||||||
|
HasScreenshot bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileChangeInfo struct {
|
||||||
|
Change repository.VersionedFileChange
|
||||||
|
Error string
|
||||||
|
|
||||||
|
// The parsed value
|
||||||
|
Parsed *resources.ParsedResource
|
||||||
|
|
||||||
|
// The title from inside the resource (or name if not found)
|
||||||
|
Title string
|
||||||
|
|
||||||
|
// The URL where this will appear (target)
|
||||||
|
GrafanaURL string
|
||||||
|
GrafanaScreenshotURL string
|
||||||
|
|
||||||
|
// URL where we can see a preview of this particular change
|
||||||
|
PreviewURL string
|
||||||
|
PreviewScreenshotURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeOptions struct {
|
||||||
|
grafanaBaseURL string
|
||||||
|
pullRequest provisioning.PullRequestJobOptions
|
||||||
|
changes []repository.VersionedFileChange
|
||||||
|
parser resources.Parser
|
||||||
|
reader repository.Reader
|
||||||
|
progress jobs.JobProgressRecorder
|
||||||
|
render ScreenshotRenderer // from config
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will process the list of versioned file changes into changeInfo
|
||||||
|
func processChangedFiles(ctx context.Context, opts changeOptions) (changeInfo, error) {
|
||||||
|
info := changeInfo{
|
||||||
|
GrafanaBaseURL: opts.grafanaBaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.render != nil {
|
||||||
|
if !opts.render.IsAvailable(ctx) {
|
||||||
|
info.MissingImageRenderer = true
|
||||||
|
opts.render = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render images when there is just one change
|
||||||
|
if len(opts.changes) > 1 {
|
||||||
|
opts.render = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logging.FromContext(ctx)
|
||||||
|
for i, change := range opts.changes {
|
||||||
|
// process maximum 10 files
|
||||||
|
if i >= 10 {
|
||||||
|
info.SkippedFiles = len(opts.changes) - i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.progress.SetMessage(ctx, fmt.Sprintf("processing: %s", change.Path))
|
||||||
|
logger.With("action", change.Action).With("path", change.Path)
|
||||||
|
|
||||||
|
v, err := calculateFileChangeInfo(ctx, info.GrafanaBaseURL, change, opts)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("error calculating changes %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If everything applied OK, then render screenshots
|
||||||
|
if opts.render != nil && v.GrafanaURL != "" && v.Parsed != nil && v.Parsed.DryRunResponse != nil {
|
||||||
|
opts.progress.SetMessage(ctx, fmt.Sprintf("rendering screenshots: %s", change.Path))
|
||||||
|
if err = v.renderScreenshots(ctx, info.GrafanaBaseURL, opts.render); err != nil {
|
||||||
|
info.MissingImageRenderer = true
|
||||||
|
if v.Error == "" {
|
||||||
|
v.Error = "Error running image rendering"
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.GrafanaScreenshotURL != "" || v.PreviewScreenshotURL != "" {
|
||||||
|
info.HasScreenshot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Changes = append(info.Changes, v)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dashboardKind = dashboard.DashboardResourceInfo.GroupVersionKind().Kind
|
||||||
|
|
||||||
|
func calculateFileChangeInfo(ctx context.Context, baseURL string, change repository.VersionedFileChange, opts changeOptions) (fileChangeInfo, error) {
|
||||||
|
if change.Action == repository.FileActionDeleted {
|
||||||
|
return calculateFileDeleteInfo(ctx, baseURL, change, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := fileChangeInfo{Change: change}
|
||||||
|
fileInfo, err := opts.reader.Read(ctx, change.Path, change.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("unable to read file", "err", err)
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file as a resource
|
||||||
|
info.Parsed, err = opts.parser.Parse(ctx, fileInfo)
|
||||||
|
if err != nil {
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a name within the file
|
||||||
|
obj := info.Parsed.Obj
|
||||||
|
info.Title = info.Parsed.Meta.FindTitle(obj.GetName())
|
||||||
|
|
||||||
|
// Check what happens when we apply changes
|
||||||
|
// NOTE: this will also invoke any server side validation
|
||||||
|
err = info.Parsed.DryRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboards get special handling
|
||||||
|
if info.Parsed.GVK.Kind == dashboardKind {
|
||||||
|
if info.Parsed.Existing != nil {
|
||||||
|
info.GrafanaURL = fmt.Sprintf("%sd/%s/%s", baseURL, obj.GetName(),
|
||||||
|
slugify.Slugify(info.Title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load this file directly
|
||||||
|
info.PreviewURL = baseURL + path.Join("admin/provisioning",
|
||||||
|
info.Parsed.Repo.Name, "dashboard/preview", info.Parsed.Info.Path)
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("ref", info.Parsed.Info.Ref)
|
||||||
|
if opts.pullRequest.URL != "" {
|
||||||
|
query.Set("pull_request_url", url.QueryEscape(opts.pullRequest.URL))
|
||||||
|
}
|
||||||
|
info.PreviewURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateFileDeleteInfo(_ context.Context, _ string, change repository.VersionedFileChange, opts changeOptions) (fileChangeInfo, error) {
|
||||||
|
// TODO -- read the old and verify
|
||||||
|
return fileChangeInfo{Change: change, Error: "delete feedback not yet implemented"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will update render the linked screenshots and update the screenshotURLs
|
||||||
|
func (f *fileChangeInfo) renderScreenshots(ctx context.Context, baseURL string, renderer ScreenshotRenderer) (err error) {
|
||||||
|
if f.GrafanaURL != "" {
|
||||||
|
f.GrafanaScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, renderer, f.Parsed.Repo, f.GrafanaURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.PreviewURL != "" {
|
||||||
|
f.PreviewScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, renderer, f.Parsed.Repo, f.PreviewURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderScreenshotFromGrafanaURL(ctx context.Context,
|
||||||
|
baseURL string,
|
||||||
|
renderer ScreenshotRenderer,
|
||||||
|
repo provisioning.ResourceRepositoryInfo,
|
||||||
|
grafanaURL string,
|
||||||
|
) (string, error) {
|
||||||
|
parsed, err := url.Parse(grafanaURL)
|
||||||
|
if err != nil {
|
||||||
|
logging.FromContext(ctx).Warn("invalid", "url", grafanaURL, "err", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
snap, err := renderer.RenderScreenshot(ctx, repo, strings.TrimPrefix(parsed.Path, "/"), parsed.Query())
|
||||||
|
if err != nil {
|
||||||
|
logging.FromContext(ctx).Warn("render failed", "url", grafanaURL, "err", err)
|
||||||
|
return "", fmt.Errorf("error rendering screenshot %w", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(snap, "://") {
|
||||||
|
return snap, nil // it is a full URL already (can happen when the blob storage returns CDN urls)
|
||||||
|
}
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("invalid base", "url", baseURL, "err", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base.JoinPath(snap).String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package pullrequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||||
|
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateChanges(t *testing.T) {
|
||||||
|
parser := resources.NewMockParser(t)
|
||||||
|
reader := repository.NewMockReader(t)
|
||||||
|
progress := jobs.NewMockJobProgressRecorder(t)
|
||||||
|
|
||||||
|
finfo := &repository.FileInfo{
|
||||||
|
Path: "path/to/file.json",
|
||||||
|
Ref: "ref",
|
||||||
|
Data: []byte("xxxx"), // not a valid JSON!
|
||||||
|
}
|
||||||
|
obj := &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||||
|
"kind": dashboardKind, // will trigger creating a URL
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "the-uid",
|
||||||
|
},
|
||||||
|
"spec": map[string]interface{}{
|
||||||
|
"title": "hello world", // has spaces
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
meta, _ := utils.MetaAccessor(obj)
|
||||||
|
|
||||||
|
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||||
|
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||||
|
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||||
|
Info: finfo,
|
||||||
|
Repo: v0alpha1.ResourceRepositoryInfo{
|
||||||
|
Namespace: "x",
|
||||||
|
Name: "y",
|
||||||
|
},
|
||||||
|
GVK: schema.GroupVersionKind{
|
||||||
|
Kind: dashboardKind,
|
||||||
|
},
|
||||||
|
Obj: obj,
|
||||||
|
Existing: obj,
|
||||||
|
Meta: meta,
|
||||||
|
DryRunResponse: obj, // avoid hitting the client
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
pullRequest := v0alpha1.PullRequestJobOptions{
|
||||||
|
Ref: "ref",
|
||||||
|
PR: 123,
|
||||||
|
URL: "http://github.com/pr/",
|
||||||
|
}
|
||||||
|
createdFileChange := repository.VersionedFileChange{
|
||||||
|
Action: repository.FileActionCreated,
|
||||||
|
Path: "path/to/file.json",
|
||||||
|
Ref: "ref",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("with-screenshot", func(t *testing.T) {
|
||||||
|
renderer := NewMockScreenshotRenderer(t)
|
||||||
|
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||||
|
renderer.On("RenderScreenshot", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(getDummyRenderedURL("x"), nil)
|
||||||
|
|
||||||
|
options := changeOptions{
|
||||||
|
grafanaBaseURL: "http://host/",
|
||||||
|
pullRequest: pullRequest,
|
||||||
|
changes: []repository.VersionedFileChange{createdFileChange},
|
||||||
|
parser: parser,
|
||||||
|
reader: reader,
|
||||||
|
progress: progress,
|
||||||
|
render: renderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := processChangedFiles(context.Background(), options)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.False(t, info.MissingImageRenderer)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"Grafana": "http://host/d/the-uid/hello-world",
|
||||||
|
"GrafanaSnapshot": "https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
"Preview": "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||||
|
"PreviewSnapshot": "https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
}, map[string]string{
|
||||||
|
"Grafana": info.Changes[0].GrafanaURL,
|
||||||
|
"GrafanaSnapshot": info.Changes[0].GrafanaScreenshotURL,
|
||||||
|
"Preview": info.Changes[0].PreviewURL,
|
||||||
|
"PreviewSnapshot": info.Changes[0].PreviewScreenshotURL,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without-screenshot", func(t *testing.T) {
|
||||||
|
renderer := NewMockScreenshotRenderer(t)
|
||||||
|
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||||
|
options := changeOptions{
|
||||||
|
grafanaBaseURL: "http://host/",
|
||||||
|
pullRequest: pullRequest,
|
||||||
|
changes: []repository.VersionedFileChange{createdFileChange},
|
||||||
|
parser: parser,
|
||||||
|
reader: reader,
|
||||||
|
progress: progress,
|
||||||
|
render: renderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := processChangedFiles(context.Background(), options)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, info.MissingImageRenderer)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"Grafana": "http://host/d/the-uid/hello-world",
|
||||||
|
"GrafanaSnapshot": "",
|
||||||
|
"Preview": "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||||
|
"PreviewSnapshot": "",
|
||||||
|
}, map[string]string{
|
||||||
|
"Grafana": info.Changes[0].GrafanaURL,
|
||||||
|
"GrafanaSnapshot": info.Changes[0].GrafanaScreenshotURL,
|
||||||
|
"Preview": info.Changes[0].PreviewURL,
|
||||||
|
"PreviewSnapshot": info.Changes[0].PreviewScreenshotURL,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("process first 10 files", func(t *testing.T) {
|
||||||
|
renderer := NewMockScreenshotRenderer(t)
|
||||||
|
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||||
|
|
||||||
|
options := changeOptions{
|
||||||
|
grafanaBaseURL: "http://host/",
|
||||||
|
pullRequest: pullRequest,
|
||||||
|
parser: parser,
|
||||||
|
reader: reader,
|
||||||
|
progress: progress,
|
||||||
|
render: renderer, // not used
|
||||||
|
}
|
||||||
|
for range 15 {
|
||||||
|
options.changes = append(options.changes, createdFileChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := processChangedFiles(context.Background(), options)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.False(t, info.MissingImageRenderer)
|
||||||
|
require.Equal(t, 10, len(info.Changes))
|
||||||
|
require.Equal(t, 5, info.SkippedFiles)
|
||||||
|
|
||||||
|
// Make sure we linked a URL, but no screenshot for each item
|
||||||
|
for _, change := range info.Changes {
|
||||||
|
require.NotEmpty(t, change.GrafanaURL)
|
||||||
|
require.Empty(t, change.GrafanaScreenshotURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDummyImageURL(t *testing.T) {
|
||||||
|
urls := []string{}
|
||||||
|
for i := range 10 {
|
||||||
|
urls = append(urls, getDummyRenderedURL(fmt.Sprintf("http://%d", i)))
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||||
|
}, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a random (but stable) image for a string
|
||||||
|
func getDummyRenderedURL(url string) string {
|
||||||
|
dummy := []string{
|
||||||
|
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||||
|
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := 0
|
||||||
|
hash := sha256.New()
|
||||||
|
bytes := hash.Sum([]byte(url))
|
||||||
|
if len(bytes) > 8 {
|
||||||
|
v := binary.BigEndian.Uint64(bytes[0:8])
|
||||||
|
idx = int(v) % len(dummy)
|
||||||
|
}
|
||||||
|
return dummy[idx]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package pullrequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commentBuilder struct {
|
||||||
|
templateDashboard *template.Template
|
||||||
|
templateTable *template.Template
|
||||||
|
templateRenderInfo *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommentBuilder() *commentBuilder {
|
||||||
|
return &commentBuilder{
|
||||||
|
templateDashboard: template.Must(template.New("dashboard").Parse(commentTemplateSingleDashboard)),
|
||||||
|
templateTable: template.Must(template.New("table").Parse(commentTemplateTable)),
|
||||||
|
templateRenderInfo: template.Must(template.New("setup").Parse(commentTemplateMissingImageRenderer)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentBuilder) Comment(ctx context.Context, prRepo PullRequestRepo, pr int, info changeInfo) error {
|
||||||
|
comment, err := c.generateComment(ctx, info)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to generate comment text: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := prRepo.CommentPullRequest(ctx, pr, comment); err != nil {
|
||||||
|
return fmt.Errorf("comment pull request: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commentBuilder) generateComment(_ context.Context, info changeInfo) (string, error) {
|
||||||
|
if len(info.Changes) == 0 {
|
||||||
|
return "no changes found", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if len(info.Changes) == 1 && info.Changes[0].Parsed.GVK.Kind == dashboardKind {
|
||||||
|
if err := c.templateDashboard.Execute(&buf, info.Changes[0]); err != nil {
|
||||||
|
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := c.templateTable.Execute(&buf, info); err != nil {
|
||||||
|
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.MissingImageRenderer {
|
||||||
|
if err := c.templateRenderInfo.Execute(&buf, info); err != nil {
|
||||||
|
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentTemplateSingleDashboard = `Hey there! 🎉
|
||||||
|
Grafana spotted some changes to your dashboard.
|
||||||
|
|
||||||
|
{{- if and .GrafanaScreenshotURL .PreviewScreenshotURL}}
|
||||||
|
### Side by Side Comparison of {{.Parsed.Info.Path}}
|
||||||
|
| Before | After |
|
||||||
|
|----------|---------|
|
||||||
|
|  |  |
|
||||||
|
{{- else if .GrafanaScreenshotURL}}
|
||||||
|
### Original of {{.Title}}
|
||||||
|

|
||||||
|
{{- else if .PreviewScreenshotURL}}
|
||||||
|
### Preview of {{.Parsed.Info.Path}}
|
||||||
|

|
||||||
|
{{ end}}
|
||||||
|
|
||||||
|
{{ if and .GrafanaURL .PreviewURL}}
|
||||||
|
See the [original]({{.GrafanaURL}}) and [preview]({{.PreviewURL}}) of {{.Parsed.Info.Path}}.
|
||||||
|
{{- else if .GrafanaURL}}
|
||||||
|
See the [original]({{.GrafanaURL}}) of {{.Title}}.
|
||||||
|
{{- else if .PreviewURL}}
|
||||||
|
See the [preview]({{.PreviewURL}}) of {{.Parsed.Info.Path}}.
|
||||||
|
{{- end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const commentTemplateTable = `Hey there! 🎉
|
||||||
|
Grafana spotted some changes.
|
||||||
|
|
||||||
|
| Action | Kind | Resource | Preview |
|
||||||
|
|--------|------|----------|---------|
|
||||||
|
{{- range .Changes}}
|
||||||
|
| {{.Parsed.Action}} | {{.Kind}} | {{.ExistingLink}} | {{ if .PreviewURL}}[preview]({{.PreviewURL}}){{ end }} |
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{ if .SkippedFiles }}
|
||||||
|
and {{ .SkippedFiles }} more files.
|
||||||
|
{{ end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
// TODO: this should expand and show links to setup docs
|
||||||
|
const commentTemplateMissingImageRenderer = `
|
||||||
|
NOTE: The image renderer is not configured
|
||||||
|
`
|
||||||
|
|
||||||
|
func (f *fileChangeInfo) Kind() string {
|
||||||
|
if f.Parsed == nil {
|
||||||
|
return filepath.Ext(f.Change.Path)
|
||||||
|
}
|
||||||
|
v := f.Parsed.GVK.Kind
|
||||||
|
if v == "" {
|
||||||
|
return filepath.Ext(f.Parsed.Info.Path)
|
||||||
|
}
|
||||||
|
return f.Parsed.GVK.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fileChangeInfo) ExistingLink() string {
|
||||||
|
if f.GrafanaURL != "" {
|
||||||
|
return fmt.Sprintf("[%s](%s)", f.Title, f.GrafanaURL)
|
||||||
|
}
|
||||||
|
return f.Title
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package pullrequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateComment(t *testing.T) {
|
||||||
|
builder := newCommentBuilder()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
Name string
|
||||||
|
Input changeInfo
|
||||||
|
}{
|
||||||
|
{"new dashboard", changeInfo{
|
||||||
|
GrafanaBaseURL: "http://host/",
|
||||||
|
Changes: []fileChangeInfo{
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "file.json",
|
||||||
|
},
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||||
|
Action: v0alpha1.ResourceActionCreate,
|
||||||
|
},
|
||||||
|
Title: "New Dashboard",
|
||||||
|
PreviewURL: "http://grafana/admin/preview",
|
||||||
|
PreviewScreenshotURL: getDummyRenderedURL("http://grafana/admin/preview"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{"update dashboard", changeInfo{
|
||||||
|
GrafanaBaseURL: "http://host/",
|
||||||
|
Changes: []fileChangeInfo{
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "file.json",
|
||||||
|
},
|
||||||
|
Action: v0alpha1.ResourceActionUpdate,
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||||
|
},
|
||||||
|
Title: "Existing Dashboard",
|
||||||
|
GrafanaURL: "http://grafana/d/uid",
|
||||||
|
PreviewURL: "http://grafana/admin/preview",
|
||||||
|
|
||||||
|
GrafanaScreenshotURL: getDummyRenderedURL("http://grafana/d/uid"),
|
||||||
|
PreviewScreenshotURL: getDummyRenderedURL("http://grafana/admin/preview"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{"update dashboard missing renderer", changeInfo{
|
||||||
|
GrafanaBaseURL: "http://host/",
|
||||||
|
Changes: []fileChangeInfo{
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "file.json",
|
||||||
|
},
|
||||||
|
Action: v0alpha1.ResourceActionUpdate,
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||||
|
},
|
||||||
|
Title: "Existing Dashboard",
|
||||||
|
GrafanaURL: "http://grafana/d/uid",
|
||||||
|
PreviewURL: "http://grafana/admin/preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MissingImageRenderer: true,
|
||||||
|
}},
|
||||||
|
{"multiple files", changeInfo{
|
||||||
|
GrafanaBaseURL: "http://host/",
|
||||||
|
SkippedFiles: 5,
|
||||||
|
Changes: []fileChangeInfo{
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "aaa.json",
|
||||||
|
},
|
||||||
|
Action: v0alpha1.ResourceActionCreate,
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||||
|
},
|
||||||
|
Title: "Dash A",
|
||||||
|
PreviewURL: "http://grafana/admin/preview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "bbb.json",
|
||||||
|
},
|
||||||
|
Action: v0alpha1.ResourceActionUpdate,
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||||
|
},
|
||||||
|
Title: "Dash B",
|
||||||
|
GrafanaURL: "http://grafana/d/bbb",
|
||||||
|
PreviewURL: "http://grafana/admin/preview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Parsed: &resources.ParsedResource{
|
||||||
|
Info: &repository.FileInfo{
|
||||||
|
Path: "bbb.json",
|
||||||
|
},
|
||||||
|
Action: v0alpha1.ResourceActionCreate,
|
||||||
|
GVK: schema.GroupVersionKind{Kind: "Playlist"},
|
||||||
|
},
|
||||||
|
Title: "My Playlist",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
comment, err := builder.generateComment(context.Background(), tc.Input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fpath := filepath.Join("testdata", strings.ReplaceAll(tc.Name, " ", "-")+".md")
|
||||||
|
update := false
|
||||||
|
|
||||||
|
// We can ignore the gosec G304 because this is only for tests
|
||||||
|
// nolint:gosec
|
||||||
|
expect, err := os.ReadFile(fpath)
|
||||||
|
if err != nil || len(expect) < 1 {
|
||||||
|
update = true
|
||||||
|
t.Error("missing " + fpath)
|
||||||
|
} else {
|
||||||
|
if diff := cmp.Diff(string(expect), comment); diff != "" {
|
||||||
|
t.Errorf("%s: %s", fpath, diff)
|
||||||
|
update = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if update {
|
||||||
|
_ = os.WriteFile(fpath, []byte(comment), 0777)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user