Merge branch 'main' into ismail/prom/3-remove-series-endpoint

This commit is contained in:
ismail simsek
2025-04-11 14:47:02 +02:00
341 changed files with 15436 additions and 5791 deletions
@@ -53,6 +53,7 @@ func (s *Service) Checks() []checks.Check {
s.pluginStore,
s.pluginContextProvider,
s.pluginClient,
s.pluginRepo,
),
plugincheck.New(
s.pluginStore,
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"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/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/util"
@@ -20,6 +21,7 @@ const (
CheckID = "datasource"
HealthCheckStepID = "health-check"
UIDValidationStepID = "uid-validation"
MissingPluginStepID = "missing-plugin"
)
type check struct {
@@ -27,6 +29,7 @@ type check struct {
PluginStore pluginstore.Store
PluginContextProvider pluginContextProvider
PluginClient plugins.Client
PluginRepo repo.Service
log log.Logger
}
@@ -35,12 +38,14 @@ func New(
pluginStore pluginstore.Store,
pluginContextProvider pluginContextProvider,
pluginClient plugins.Client,
pluginRepo repo.Service,
) checks.Check {
return &check{
DatasourceSvc: datasourceSvc,
PluginStore: pluginStore,
PluginContextProvider: pluginContextProvider,
PluginClient: pluginClient,
PluginRepo: pluginRepo,
log: log.New("advisor.datasourcecheck"),
}
}
@@ -69,6 +74,11 @@ func (c *check) Steps() []checks.Step {
PluginClient: c.PluginClient,
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)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotRegistered) {
// The plugin is not installed
return checks.NewCheckReportFailure(
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
// The plugin is not installed, handle this in the missing plugin step
return nil, nil
}
// Unable to check health check
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
}
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 {
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
}
@@ -2,6 +2,7 @@ package datasourcecheck
import (
"context"
"errors"
"testing"
"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/infra/log"
"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/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/user"
"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) {
t.Run("should return no failures when all datasources are healthy", func(t *testing.T) {
datasources := []*datasources.DataSource{
@@ -24,30 +51,20 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
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)
})
@@ -59,30 +76,20 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
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.Equal(t, "uid-validation", failures[0].StepID)
})
@@ -95,30 +102,20 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError}}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
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.Equal(t, "health-check", failures[0].StepID)
})
@@ -130,31 +127,21 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{err: plugins.ErrMethodNotImplemented}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
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, 0)
assert.Empty(t, failures)
})
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}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{err: plugins.ErrPluginNotRegistered}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
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.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 {
@@ -199,7 +228,7 @@ type MockDatasourceSvc struct {
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
}
@@ -207,7 +236,7 @@ type MockPluginContextProvider struct {
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
}
@@ -218,6 +247,29 @@ type MockPluginClient struct {
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
}
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
}
+6 -6
View File
@@ -1,9 +1,9 @@
package kinds
import (
"github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
"github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
"github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
v0 "github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
v1 "github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
v2 "github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
)
// Status is the shared status of all dashboard versions.
@@ -51,19 +51,19 @@ dashboard: {
versions: {
"v0alpha1": {
schema: {
spec: v0alpha1.DashboardSpec
spec: v0.DashboardSpec
status: DashboardStatus
}
}
"v1alpha1": {
schema: {
spec: v1alpha1.DashboardSpec
spec: v1.DashboardSpec
status: DashboardStatus
}
}
"v2alpha1": {
schema: {
spec: v2alpha1.DashboardSpec
spec: v2.DashboardSpec
status: DashboardStatus
}
}
@@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@@ -523,8 +528,8 @@ GridLayoutRowSpec: {
y: int
collapsed: bool
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.
repeat?: RowRepeatOptions
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
}
GridLayoutSpec: {
@@ -604,6 +609,7 @@ TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
conditionalRendering?: ConditionalRenderingGroupKind
repeat?: TabRepeatOptions
}
PanelSpec: {
@@ -1069,6 +1069,7 @@ type DashboardTabsLayoutTabSpec struct {
Title *string `json:"title,omitempty"`
Layout DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"`
Repeat *DashboardTabRepeatOptions `json:"repeat,omitempty"`
}
// 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
// +k8s:openapi-gen=true
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.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.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.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),
@@ -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 {
return common.OpenAPIDefinition{
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"),
},
},
"repeat": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"),
},
},
},
Required: []string{"layout"},
},
},
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/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
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/schemaversion"
)
func RegisterConversions(s *runtime.Scheme) error {
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V1(a.(*v0alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V1(a.(*dashv0.Dashboard), b.(*dashv1.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2(a.(*v0alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2(a.(*dashv0.Dashboard), b.(*dashv2.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V0(a.(*v1alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V0(a.(*dashv1.Dashboard), b.(*dashv0.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V2(a.(*v1alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V2(a.(*dashv1.Dashboard), b.(*dashv2.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V0(a.(*v2alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V0(a.(*dashv2.Dashboard), b.(*dashv0.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V1(a.(*v2alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V1(a.(*dashv2.Dashboard), b.(*dashv1.Dashboard), scope)
}); err != nil {
return err
}
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.Spec.Object = in.Spec.Object
out.Status = v1alpha1.DashboardStatus{
Conversion: &v1alpha1.DashboardConversionStatus{
StoredVersion: v0alpha1.VERSION,
out.Status = dashv1.DashboardStatus{
Conversion: &dashv1.DashboardConversionStatus{
StoredVersion: dashv0.VERSION,
},
}
@@ -64,7 +64,7 @@ func Convert_V0_to_V1(in *v0alpha1.Dashboard, out *v1alpha1.Dashboard, scope con
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
// 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.
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: v2alpha1.DashboardGridLayoutSpec{},
Spec: dashv2.DashboardGridLayoutSpec{},
},
}
out.Status = v2alpha1.DashboardStatus{
Conversion: &v2alpha1.DashboardConversionStatus{
StoredVersion: v0alpha1.VERSION,
out.Status = dashv2.DashboardStatus{
Conversion: &dashv2.DashboardConversionStatus{
StoredVersion: dashv0.VERSION,
Failed: true,
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
}
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.Spec.Object = in.Spec.Object
out.Status = v0alpha1.DashboardStatus{
Conversion: &v0alpha1.DashboardConversionStatus{
StoredVersion: v1alpha1.VERSION,
out.Status = dashv0.DashboardStatus{
Conversion: &dashv0.DashboardConversionStatus{
StoredVersion: dashv1.VERSION,
},
}
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
// 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.
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: v2alpha1.DashboardGridLayoutSpec{},
Spec: dashv2.DashboardGridLayoutSpec{},
},
}
out.Status = v2alpha1.DashboardStatus{
Conversion: &v2alpha1.DashboardConversionStatus{
StoredVersion: v1alpha1.VERSION,
out.Status = dashv2.DashboardStatus{
Conversion: &dashv2.DashboardConversionStatus{
StoredVersion: dashv1.VERSION,
Failed: true,
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
}
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
// TODO: implement V2 to V0 conversion
out.Status = v0alpha1.DashboardStatus{
Conversion: &v0alpha1.DashboardConversionStatus{
StoredVersion: v2alpha1.VERSION,
out.Status = dashv0.DashboardStatus{
Conversion: &dashv0.DashboardConversionStatus{
StoredVersion: dashv2.VERSION,
Failed: true,
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
}
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
// TODO: implement V2 to V1 conversion
out.Status = v1alpha1.DashboardStatus{
Conversion: &v1alpha1.DashboardConversionStatus{
StoredVersion: v2alpha1.VERSION,
out.Status = dashv1.DashboardStatus{
Conversion: &dashv1.DashboardConversionStatus{
StoredVersion: dashv2.VERSION,
Failed: true,
Error: "backend conversion not yet implemented",
},
@@ -9,18 +9,18 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestConversionMatrixExist(t *testing.T) {
versions := []v1.Object{
&v0alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
&v1alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
&v2alpha1.Dashboard{Spec: v2alpha1.DashboardSpec{Title: "dashboardV2"}},
&dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
&dashv1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
&dashv2.Dashboard{Spec: dashv2.DashboardSpec{Title: "dashboardV2"}},
}
scheme := runtime.NewScheme()
@@ -47,7 +47,7 @@ func TestConversionMatrixExist(t *testing.T) {
}
func TestDeepCopyValid(t *testing.T) {
dash1 := &v0alpha1.Dashboard{}
dash1 := &dashv0.Dashboard{}
meta1, err := utils.MetaAccessor(dash1)
require.NoError(t, err)
meta1.SetFolder("f1")
+3
View File
@@ -472,6 +472,9 @@ default_home_dashboard_path =
# Dashboards UIDs to report performance metrics for. * can be used to report metrics for all dashboards
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 #########################
[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.
@@ -190,7 +190,6 @@ Experimental features might be changed or removed without prior notice.
| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics |
| `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 |
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
| `rolePickerDrawer` | Enables the new role picker drawer design |
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
| `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');
}
};
+256
View File
@@ -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
}
+1 -1
View File
@@ -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/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/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/apiserver v0.0.0-20250325075903-77fa2271be7a // @grafana/grafana-app-platform-squad
@@ -227,6 +227,7 @@ export interface GrafanaConfig {
rudderstackIntegrationsUrl: string | undefined;
analyticsConsoleReporting: boolean;
dashboardPerformanceMetrics: string[];
panelSeriesLimit: number;
sqlConnectionLimits: SqlConnectionLimits;
sharedWithMeFolderUID?: string;
rootFolderUID?: string;
@@ -246,6 +247,7 @@ export interface GrafanaConfig {
* Grafana's supported language.
*/
language: string | undefined;
locale: string;
}
export interface SqlConnectionLimits {
@@ -725,10 +725,6 @@ export interface FeatureToggles {
*/
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
*/
groupAttributeSync?: boolean;
+1
View File
@@ -269,6 +269,7 @@ export const availableIconsIndex = {
'add-user': true,
attach: true,
'dollar-alt': true,
'ai-sparkle': true,
};
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;
}
if (isPromFlavor(idToMatch)) {
return isPromFlavor(pluginMeta.id);
}
if (pluginMeta.aliasIDs) {
return pluginMeta.aliasIDs.includes(idToMatch);
}
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 (
<AsyncSelect
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect}
isClearable={Boolean(variableEditor)}
isClearable={true}
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
+9
View File
@@ -184,6 +184,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
rudderstackIntegrationsUrl: undefined;
analyticsConsoleReporting = false;
dashboardPerformanceMetrics: string[] = [];
panelSeriesLimit = 0;
sqlConnectionLimits = {
maxOpenConns: 100,
maxIdleConns: 100,
@@ -208,6 +209,12 @@ export class GrafanaBootConfig implements GrafanaConfig {
*/
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) {
this.bootData = options.bootData;
@@ -243,6 +250,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
this.theme2 = getThemeById(this.bootData.user.theme);
this.bootData.user.lightTheme = this.theme2.isLight;
this.theme = this.theme2.v1;
this.locale = options.bootData.user.locale;
}
geomapDefaultBaseLayer?: MapLayerOptions<any> | undefined;
listDashboardScopesEndpoint?: string | undefined;
@@ -25,3 +25,5 @@ export {
setGetObservablePluginLinks,
type 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 './LocationService';
export * from './appEvents';
export * from './SidecarService_EXPERIMENTAL';
export * from './SidecarContext_EXPERIMENTAL';
export {
setPluginComponentHook,
@@ -37,6 +37,14 @@ class MyDataSource extends DataSourceWithBackend<MyQuery, DataSourceJsonData> {
applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[] | undefined): MyQuery {
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[]>();
@@ -536,6 +544,15 @@ describe('DataSourceWithBackend', () => {
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() {
@@ -34,6 +34,7 @@ import {
import { publicDashboardQueryHandler } from './publicDashboardQueryHandler';
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse';
import { UserStorage } from './userStorage';
/**
* @internal
@@ -121,8 +122,11 @@ class DataSourceWithBackend<
TQuery extends DataQuery = DataQuery,
TOptions extends DataSourceJsonData = DataSourceJsonData,
> extends DataSourceApi<TQuery, TOptions> {
protected userStorage: UserStorage;
constructor(instanceSettings: DataSourceInstanceSettings<TOptions>) {
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 () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
await storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalledWith('plugin-id:abc:key');
});
it('use localStorage if the user storage is not found', async () => {
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
const storage = usePluginUserStorage();
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 () => {
@@ -73,8 +73,8 @@ describe('userStorage', () => {
it('use localStorage if the user is not logged in', async () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalled();
await storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalledWith('plugin-id:abc:key', 'value');
});
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.
* 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 resourceName: string;
private userUID: string;
@@ -76,13 +76,13 @@ class UserStorage {
async getItem(key: string): Promise<string | null> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
return localStorage.getItem(this.resourceName);
return localStorage.getItem(`${this.resourceName}:${key}`);
}
// Ensure this.storageSpec is initialized
await this.init();
if (!this.storageSpec) {
// Also, fallback to localStorage for backward compatibility
return localStorage.getItem(this.resourceName);
return localStorage.getItem(`${this.resourceName}:${key}`);
}
return this.storageSpec.data[key];
}
@@ -90,7 +90,7 @@ class UserStorage {
async setItem(key: string, value: string): Promise<void> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
localStorage.setItem(key, value);
localStorage.setItem(`${this.resourceName}:${key}`, value);
return;
}
@@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode,
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@@ -603,6 +608,7 @@ TabsLayoutTabKind: {
TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
repeat?: TabRepeatOptions
conditionalRendering?: ConditionalRenderingGroupKind
}
@@ -962,4 +968,4 @@ ConditionalRenderingTimeRangeSizeKind: {
ConditionalRenderingTimeRangeSizeSpec: {
value: string
}
}
@@ -327,7 +327,7 @@ export interface FieldConfig {
description?: string;
// 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}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@@ -916,6 +916,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
repeat?: TabRepeatOptions;
conditionalRendering?: ConditionalRenderingGroupKind;
}
@@ -923,6 +924,16 @@ export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
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
export interface DashboardLink {
// Title to display with the link
@@ -1492,4 +1503,3 @@ export const defaultVariableValueOption = (): VariableValueOption => ({
label: "",
value: defaultVariableValueSingle(),
});
@@ -282,7 +282,7 @@ export interface FieldConfig {
description?: string;
// 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}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@@ -872,12 +872,23 @@ export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
conditionalRendering?: ConditionalRenderingGroupKind;
repeat?: TabRepeatOptions;
}
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
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
export interface DashboardLink {
// Title to display with the link
@@ -1402,4 +1413,3 @@ export const defaultSpec = (): Spec => ({
title: "",
variables: [],
});
@@ -212,6 +212,8 @@ export const getCheckboxStyles = (theme: GrafanaTheme2, invalid = false) => {
gridRowStart: 2,
lineHeight: theme.typography.bodySmall.lineHeight,
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('height', `fit-content`);
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('height');
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;
// First pass: Assign minimum widths to columns that need it
columns.forEach((column) => {
if (!column.width && column.minWidth! > sharedWidth) {
column.width = column.minWidth;
availableWidth -= column.width!;
fieldCountWithoutWidth -= 1;
for (let i = fieldCountWithoutWidth; i > 0; i--) {
for (const column of columns) {
if (!column.width && column.minWidth! > sharedWidth) {
column.width = column.minWidth;
availableWidth -= column.width!;
fieldCountWithoutWidth -= 1;
sharedWidth = availableWidth / fieldCountWithoutWidth;
}
}
});
}
// Recalculate shared width after assigning minimum widths
sharedWidth = availableWidth / fieldCountWithoutWidth;
// Second pass: Assign shared width to remaining columns
columns.forEach((column) => {
// divide up the rest of the space
for (const column of columns) {
if (!column.width) {
column.width = sharedWidth;
}
column.minWidth = COLUMN.MIN_WIDTH; // Ensure min-width is always set
});
column.minWidth = COLUMN.MIN_WIDTH;
}
return columns;
}
@@ -980,6 +978,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
'--rdg-summary-border-color': theme.colors.border.medium,
'.rdg-cell': {
// Prevent collisions with custom cell components
zIndex: 2,
borderRight: 'none',
},
},
+16 -14
View File
@@ -16,20 +16,22 @@ var logger = log.New("datasource")
// requiredURL contains the set of data sources that require a URL.
var requiredURL = map[string]bool{
datasources.DS_GRAPHITE: true,
datasources.DS_INFLUXDB: true,
datasources.DS_INFLUXDB_08: true,
datasources.DS_ES: true,
datasources.DS_PROMETHEUS: true,
datasources.DS_ALERTMANAGER: true,
datasources.DS_JAEGER: true,
datasources.DS_LOKI: true,
datasources.DS_OPENTSDB: true,
datasources.DS_TEMPO: true,
datasources.DS_ZIPKIN: true,
datasources.DS_MYSQL: true,
datasources.DS_POSTGRES: true,
datasources.DS_MSSQL: true,
datasources.DS_GRAPHITE: true,
datasources.DS_INFLUXDB: true,
datasources.DS_INFLUXDB_08: true,
datasources.DS_ES: true,
datasources.DS_PROMETHEUS: true,
datasources.DS_AMAZON_PROMETHEUS: true,
datasources.DS_AZURE_PROMETHEUS: true,
datasources.DS_ALERTMANAGER: true,
datasources.DS_JAEGER: true,
datasources.DS_LOKI: true,
datasources.DS_OPENTSDB: true,
datasources.DS_TEMPO: true,
datasources.DS_ZIPKIN: true,
datasources.DS_MYSQL: true,
datasources.DS_POSTGRES: true,
datasources.DS_MSSQL: true,
}
// URLValidationError represents an error from validating a data source URL.
+1
View File
@@ -194,6 +194,7 @@ type FrontendSettingsDTO struct {
AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting"`
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
PanelSeriesLimit int `json:"panelSeriesLimit"`
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
+2 -2
View File
@@ -15,7 +15,7 @@ import (
clientrest "k8s.io/client-go/rest"
"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"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
@@ -618,7 +618,7 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
cfg := setting.NewCfg()
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
folderv0alpha1.RESOURCEGROUP: {
folders.RESOURCEGROUP: {
DualWriterMode: tc.unifiedStorageMode,
},
}
+2 -1
View File
@@ -216,6 +216,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
@@ -551,7 +552,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
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
ds.JsonData.Set("directUrl", ds.URL)
}
+29 -4
View File
@@ -23,6 +23,25 @@ import (
"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) {
c, span := hs.injectSpan(c, "api.setIndexViewData")
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
// translating words in the interface
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
urlPrefs := getURLPrefs(c)
if prefs.JSONData.Language != "" {
if urlPrefs.Language != "" {
language = urlPrefs.Language
} else if 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 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
}
}
@@ -88,7 +113,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
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)
if err != nil {
return nil, err
+1 -1
View File
@@ -313,7 +313,7 @@ func (proxy *DataSourceProxy) validateRequest() error {
}
// 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" {
return errors.New("non allow-listed DELETEs not allowed on proxied Prometheus datasource")
}
@@ -3,4 +3,4 @@
// +k8s:defaulter-gen=TypeMeta
// +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 (
"fmt"
@@ -12,7 +12,7 @@ import (
const (
GROUP = "folder.grafana.app"
VERSION = "v0alpha1"
VERSION = "v1"
RESOURCE = "folders"
APIVERSION = GROUP + "/" + VERSION
RESOURCEGROUP = RESOURCE + "." + GROUP
@@ -1,4 +1,4 @@
package v0alpha1
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -5,7 +5,7 @@
// Code generated by deepcopy-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
@@ -5,7 +5,7 @@
// Code generated by defaulter-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
@@ -5,7 +5,7 @@
// Code generated by openapi-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
common "k8s.io/kube-openapi/pkg/common"
@@ -14,18 +14,18 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) 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/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_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/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_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/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_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/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.DescendantCounts": schema_pkg_apis_folder_v1_DescendantCounts(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.Folder": schema_pkg_apis_folder_v1_Folder(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderAccessInfo": schema_pkg_apis_folder_v1_FolderAccessInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo": schema_pkg_apis_folder_v1_FolderInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfoList": schema_pkg_apis_folder_v1_FolderInfoList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderList": schema_pkg_apis_folder_v1_FolderList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats": schema_pkg_apis_folder_v1_ResourceStats(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{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@@ -52,7 +52,7 @@ func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallba
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
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{
"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{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@@ -96,18 +96,18 @@ func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common
"spec": {
SchemaProps: spec.SchemaProps{
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{
"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{
Schema: spec.Schema{
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{
Schema: spec.Schema{
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{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@@ -256,7 +256,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
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{
"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{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@@ -303,7 +303,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) co
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
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{
"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{
Schema: spec.Schema{
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{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@@ -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/v0alpha1,FolderInfoList,Items
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/v1,FolderInfoList,Items
@@ -12,7 +12,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
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"
"github.com/grafana/grafana/pkg/apimachinery/identity"
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/metrics"
"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
// 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")
defer span.End()
// isFiring and isNoData contains the outcome of ConditionsCmd, and is derived from the
+1 -1
View File
@@ -639,7 +639,7 @@ func TestConditionsCmd(t *testing.T) {
for _, tt := range tests {
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.Equal(t, tt.expected(), res)
})
+6 -4
View File
@@ -12,13 +12,15 @@ import (
"go.opentelemetry.io/otel/attribute"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/metrics"
"github.com/grafana/grafana/pkg/infra/tracing"
)
// Command is an interface for all expression commands.
type Command interface {
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
}
@@ -69,7 +71,7 @@ func (gm *MathCommand) NeedsVars() []string {
// Execute runs the command and returns the results or an error if the command
// 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.SetAttributes(attribute.String("expression", gm.RawExpression))
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
// 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")
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
// 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")
defer span.End()
newRes := mathexp.Results{}
+6 -6
View File
@@ -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.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) {
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.DropNonNumber{})
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.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) {
cmd, err := NewReduceCommand(util.GenerateShortUID(), randomReduceFunc(), varToReduce, &mathexp.ReplaceNonNumberWithValue{Value: 1})
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.Len(t, execute.Values, len(numbers))
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)
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.Len(t, results.Values, 1)
@@ -253,7 +253,7 @@ func TestResampleCommand_Execute(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
varToReduce: mathexp.Results{Values: mathexp.Values{test.vals}},
}, tracing.InitializeTracerForTest())
}, tracing.InitializeTracerForTest(), nil)
if test.isError {
require.Error(t, err)
} 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) {
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
varToReduce: mathexp.Results{Values: mathexp.Values{nil}},
}, tracing.InitializeTracerForTest())
}, tracing.InitializeTracerForTest(), nil)
require.Empty(t, result.Values)
require.NoError(t, err)
})
+1 -1
View File
@@ -182,7 +182,7 @@ func getResponseFrame(logger *log.ConcreteLogger, resp *backend.QueryDataRespons
}
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
}
allVector := false
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"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/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -21,7 +22,7 @@ func TestConvertDataFramesToResults(t *testing.T) {
cfg: setting.NewCfg(),
features: featuremgmt.WithFeatures(),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
metrics: metrics.NewSSEMetrics(nil),
}
converter := &ResultConverter{Features: s.features, Tracer: s.tracer}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"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/tracing"
"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{},
nil, pluginconfig.NewFakePluginRequestConfigProvider()),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
metrics: metrics.NewSSEMetrics(nil),
converter: &ResultConverter{
Features: features,
Tracer: tracing.InitializeTracerForTest(),
+7 -6
View File
@@ -9,6 +9,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/metrics"
"github.com/grafana/grafana/pkg/infra/tracing"
)
@@ -32,7 +33,7 @@ func (h *HysteresisCommand) NeedsVars() []string {
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]
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
}
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
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))
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
return h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
return h.UnloadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
}
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}
loadingResults, err := h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer)
loadingResults, err := h.LoadingThresholdFunc.Execute(traceCtx, now, vars, tracer, metrics)
if err != nil {
return mathexp.Results{}, fmt.Errorf("failed to execute loading threshold: %w", err)
}
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 {
return mathexp.Results{}, fmt.Errorf("failed to execute unloading threshold: %w", err)
}
+1 -1
View File
@@ -108,7 +108,7 @@ func TestHysteresisExecute(t *testing.T) {
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
"A": mathexp.Results{Values: tc.input},
}, tracer)
}, tracer, nil)
if tc.expectedError != nil {
require.ErrorIs(t, err, tc.expectedError)
return
-47
View File
@@ -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
}
+125
View File
@@ -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
View File
@@ -86,7 +86,7 @@ func (m *MLNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s *
}
logger.Debug("Data source queried", "responseType", 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.
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/expr/metrics"
"github.com/grafana/grafana/pkg/expr/ml"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/user"
@@ -61,7 +62,7 @@ func TestMLNodeExecute(t *testing.T) {
features: nil,
pluginsClient: pluginsClient,
tracer: nil,
metrics: newMetrics(nil),
metrics: metrics.NewSSEMetrics(nil),
}
cmdResponse := data.NewFrame("test",
@@ -198,7 +199,7 @@ func TestMLNodeExecute(t *testing.T) {
features: nil,
pluginsClient: pluginsClient,
tracer: nil,
metrics: newMetrics(nil),
metrics: metrics.NewSSEMetrics(nil),
}
cmd := &ml.FakeCommand{
+3 -3
View File
@@ -103,7 +103,7 @@ func (gn *CMDNode) NeedsVars() []string {
// other nodes they must have already been executed and their results must
// already by in vars.
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) {
@@ -320,7 +320,7 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars
}
logger.Debug("Data source queried", "responseType", responseType)
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)
@@ -395,7 +395,7 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s
}
logger.Debug("Data source queried", "responseType", responseType)
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)
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/plugins"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -65,7 +66,7 @@ type Service struct {
pluginsClient backend.CallResourceHandler
tracer tracing.Tracer
metrics *metrics
metrics *metrics.ExprMetrics
}
type pluginContextProvider interface {
@@ -81,7 +82,7 @@ func ProvideService(cfg *setting.Cfg, pluginClient plugins.Client, pCtxProvider
pCtxProvider: pCtxProvider,
features: features,
tracer: tracer,
metrics: newMetrics(registerer),
metrics: metrics.NewSSEMetrics(registerer),
pluginsClient: pluginClient,
converter: &ResultConverter{
Features: features,
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/require"
"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/plugins"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -249,7 +250,7 @@ func newMockQueryService(responses map[string]backend.DataResponse, queries []Qu
pCtxProvider: pCtxProvider,
features: featuremgmt.WithFeatures(),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
metrics: metrics.NewSSEMetrics(nil),
converter: &ResultConverter{
Features: features,
Tracer: tracing.InitializeTracerForTest(),
+20 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"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/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
// 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")
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{}
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...)
}
totalCells := totalCells(allFrames)
tc = totalCells(allFrames)
// 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{},
fmt.Errorf(
"SQL expression: total cell count across all input tables exceeds limit of %d. Total cells: %d",
gr.limit,
totalCells,
tc,
)
}
+25 -1
View File
@@ -10,6 +10,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"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"
"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 {
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 {
trace.Tracer
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data"
"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/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
@@ -174,7 +175,7 @@ func (tc *ThresholdCommand) NeedsVars() []string {
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 {
if maybeValue == nil {
return nil
+5 -5
View File
@@ -33,7 +33,7 @@ func BenchmarkThreshold(b *testing.B) {
}
b.ResetTimer()
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) {
@@ -43,7 +43,7 @@ func BenchmarkThreshold(b *testing.B) {
}
b.ResetTimer()
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) {
@@ -53,7 +53,7 @@ func BenchmarkThreshold(b *testing.B) {
}
b.ResetTimer()
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) {
@@ -72,7 +72,7 @@ func BenchmarkThreshold(b *testing.B) {
}
b.ResetTimer()
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) {
@@ -82,7 +82,7 @@ func BenchmarkThreshold(b *testing.B) {
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = greater.Execute(ctx, timeNow, vars, trace)
_, _ = greater.Execute(ctx, timeNow, vars, trace, nil)
}
})
}
+1 -1
View File
@@ -639,7 +639,7 @@ func TestThresholdExecute(t *testing.T) {
t.Run(name, func(t *testing.T) {
result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
"A": newResults(input[name]),
}, tracing.InitializeTracerForTest())
}, tracing.InitializeTracerForTest(), nil)
require.NoError(t, err)
require.Equal(t, newResults(tc.expected[name]), result)
})
+1 -1
View File
@@ -81,7 +81,7 @@ func (s *Service) TransformData(ctx context.Context, now time.Time, req *Request
respStatus = "failure"
}
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()
}()
@@ -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)
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.SetLimit(10)
flavors := sync.Map{}
for _, ds := range dataSources {
for _, ds := range allPromDataSources {
ds := ds
g.Go(func() error {
variant, err := s.detectPrometheusVariant(ctx, ds)
@@ -32,6 +32,18 @@ func TestDetectPrometheusVariant(t *testing.T) {
}))
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()
statsService := statstest.NewFakeService()
s := createService(
@@ -80,6 +92,26 @@ func TestDetectPrometheusVariant(t *testing.T) {
Access: "proxy",
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(1), flavors["vanilla"])
assert.Equal(t, int64(1), flavors["cortex-like"])
assert.Equal(t, int64(3), flavors["cortex-like"])
}
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"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) {
@@ -21,7 +21,7 @@ func TestLargeDashboardSupport(t *testing.T) {
f, err := os.ReadFile(devdash)
require.NoError(t, err)
dash := &dashboardv1alpha1.Dashboard{
dash := &dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "test",
@@ -38,7 +38,7 @@ func TestLargeDashboardSupport(t *testing.T) {
scheme := runtime.NewScheme()
err = dashboardv1alpha1.AddToScheme(scheme)
err = dashv1.AddToScheme(scheme)
require.NoError(t, err)
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
@@ -56,7 +56,7 @@ func TestLargeDashboardSupport(t *testing.T) {
}`, string(small))
// Now make it big again
rehydratedDash := &dashboardv1alpha1.Dashboard{
rehydratedDash := &dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "test",
@@ -13,7 +13,7 @@ import (
authlib "github.com/grafana/authlib/types"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"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/services/provisioning"
"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
for i := 1; rows.Next(); i++ {
dash := rows.row.Dash
dash.APIVersion = "folder.grafana.app/v0alpha1"
dash.APIVersion = "folder.grafana.app/v1"
dash.Kind = "Folder"
dash.SetNamespace(opts.Namespace)
dash.SetResourceVersion("") // it will be filled in by the backend
@@ -132,7 +132,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
}
dash := &dashboard.Dashboard{
TypeMeta: metav1.TypeMeta{
APIVersion: "dashboard.grafana.app/v1alpha1",
APIVersion: dashboard.APIVERSION,
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-dash",
@@ -170,7 +170,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
&dashboards.Dashboard{
ID: 1234,
Version: 2,
APIVersion: "dashboard.grafana.app/v1alpha1",
APIVersion: dashboard.APIVERSION,
}, nil).Once()
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
require.NoError(t, err)
@@ -14,7 +14,7 @@ import (
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/dashboardaccess"
"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 {
case dashboard.DASHBOARD_RESOURCE:
queryType = searchstore.TypeDashboard
case folderv0alpha1.RESOURCE:
case folders.RESOURCE:
queryType = searchstore.TypeFolder
default:
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 &&
((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
}
@@ -336,8 +336,8 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
if item.IsFolder {
return &resource.ResourceKey{
Namespace: namespace,
Group: folderv0alpha1.GROUP,
Resource: folderv0alpha1.RESOURCE,
Group: folders.GROUP,
Resource: folders.RESOURCE,
Name: item.UID,
}
}
@@ -401,7 +401,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.Reso
switch parts[0] {
case dashboard.GROUP:
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, false)
case folderv0alpha1.GROUP:
case folders.GROUP:
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
default:
return nil, fmt.Errorf("invalid group")
+8 -8
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -25,7 +25,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
}{
{
name: "should skip non-create/update operations",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(123),
@@ -37,7 +37,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
},
{
name: "v0 should extract id and set as label",
inputObj: &v0alpha1.Dashboard{
inputObj: &dashv0.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"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",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(456),
@@ -63,7 +63,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
},
{
name: "v1 should not error mutation hook if migration fails",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(456),
@@ -100,10 +100,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
require.Equal(t, tt.expectedID, meta.GetDeprecatedInternalID()) //nolint:staticcheck
switch v := tt.inputObj.(type) {
case *v0alpha1.Dashboard:
case *dashv0.Dashboard:
_, exists := v.Spec.Object["id"]
require.False(t, exists, "id should be removed from spec")
case *v1alpha1.Dashboard:
case *dashv1.Dashboard:
_, exists := v.Spec.Object["id"]
require.False(t, exists, "id should be removed from spec")
schemaVersion, ok := v.Spec.Object["schemaVersion"].(int)
+32 -32
View File
@@ -20,9 +20,9 @@ import (
claims "github.com/grafana/authlib/types"
internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/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/client"
)
@@ -113,7 +113,7 @@ func RegisterAPIService(
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
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{
log: log.New("grafana-apiserver.dashboards"),
@@ -146,28 +146,28 @@ func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
// If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
return []schema.GroupVersion{
v2alpha1.DashboardResourceInfo.GroupVersion(),
v0alpha1.DashboardResourceInfo.GroupVersion(),
v1alpha1.DashboardResourceInfo.GroupVersion(),
dashv2.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
}
}
return []schema.GroupVersion{
v1alpha1.DashboardResourceInfo.GroupVersion(),
v0alpha1.DashboardResourceInfo.GroupVersion(),
v2alpha1.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv2.DashboardResourceInfo.GroupVersion(),
}
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
b.scheme = scheme
if err := v0alpha1.AddToScheme(scheme); err != nil {
if err := dashv0.AddToScheme(scheme); err != nil {
return err
}
if err := v1alpha1.AddToScheme(scheme); err != nil {
if err := dashv1.AddToScheme(scheme); err != nil {
return err
}
if err := v2alpha1.AddToScheme(scheme); err != nil {
if err := dashv2.AddToScheme(scheme); err != nil {
return err
}
@@ -370,13 +370,13 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
// Extract properties based on the object's type
switch d := obj.(type) {
case *v0alpha1.Dashboard:
case *dashv0.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *v1alpha1.Dashboard:
case *dashv1.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *v2alpha1.Dashboard:
case *dashv2.Dashboard:
title = d.Spec.Title
refresh = d.Spec.TimeSettings.AutoRefresh
default:
@@ -401,15 +401,15 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
largeObjects = NewDashboardLargeObjectSupport(opts.Scheme, opts.StorageOpts.BlobThresholdBytes)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptsRegister(v0alpha1.DashboardResourceInfo.GroupResource(), storageOpts)
opts.StorageOptsRegister(dashv0.DashboardResourceInfo.GroupResource(), storageOpts)
// v0alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
v0alpha1.DashboardResourceInfo,
&v0alpha1.LibraryPanelResourceInfo,
dashv0.DashboardResourceInfo,
&dashv0.LibraryPanelResourceInfo,
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &v0alpha1.DashboardWithAccessInfo{}
dash, ok := obj.(*v0alpha1.Dashboard)
dto := &dashv0.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv0.Dashboard)
if ok {
dto.Dashboard = *dash
}
@@ -423,11 +423,11 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
// v1alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
v1alpha1.DashboardResourceInfo,
dashv1.DashboardResourceInfo,
nil, // do not register library panel
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &v1alpha1.DashboardWithAccessInfo{}
dash, ok := obj.(*v1alpha1.Dashboard)
dto := &dashv1.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv1.Dashboard)
if ok {
dto.Dashboard = *dash
}
@@ -441,11 +441,11 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
// v2alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
v2alpha1.DashboardResourceInfo,
dashv2.DashboardResourceInfo,
nil, // do not register library panel
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &v2alpha1.DashboardWithAccessInfo{}
dash, ok := obj.(*v2alpha1.Dashboard)
dto := &dashv2.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv2.Dashboard)
if ok {
dto.Dashboard = *dash
}
@@ -515,9 +515,9 @@ func (b *DashboardsAPIBuilder) storageForVersion(
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
defs := v0alpha1.GetOpenAPIDefinitions(ref)
maps.Copy(defs, v1alpha1.GetOpenAPIDefinitions(ref))
maps.Copy(defs, v2alpha1.GetOpenAPIDefinitions(ref))
defs := dashv0.GetOpenAPIDefinitions(ref)
maps.Copy(defs, dashv1.GetOpenAPIDefinitions(ref))
maps.Copy(defs, dashv2.GetOpenAPIDefinitions(ref))
return defs
}
}
@@ -528,7 +528,7 @@ func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
}
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
}
+20 -20
View File
@@ -5,9 +5,9 @@ import (
"fmt"
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -24,7 +24,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
zeroInt64 := int64(0)
tests := []struct {
name string
inputObj *v1alpha1.Dashboard
inputObj *dashv1.Dashboard
deletionOptions metav1.DeleteOptions
dashboardResponse *dashboards.DashboardProvisioning
dashboardErrorResponse error
@@ -33,7 +33,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
}{
{
name: "should return an error if data is found",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
@@ -52,7 +52,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
},
{
name: "should return an error if unable to check",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
@@ -71,7 +71,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
},
{
name: "should be okay if error is provisioned dashboard not found",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
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",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
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",
inputObj: &v1alpha1.Dashboard{
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
@@ -137,10 +137,10 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.inputObj,
nil,
v1alpha1.DashboardResourceInfo.GroupVersionKind(),
dashv1.DashboardResourceInfo.GroupVersionKind(),
"stacks-123",
tt.inputObj.Name,
v1alpha1.DashboardResourceInfo.GroupVersionResource(),
dashv1.DashboardResourceInfo.GroupVersionResource(),
"",
admission.Operation("DELETE"),
&tt.deletionOptions,
@@ -173,9 +173,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
name: "should return v1alpha1 by default",
enabledFeatures: []string{},
expected: []schema.GroupVersion{
v1alpha1.DashboardResourceInfo.GroupVersion(),
v0alpha1.DashboardResourceInfo.GroupVersion(),
v2alpha1.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv2.DashboardResourceInfo.GroupVersion(),
},
},
{
@@ -184,9 +184,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
featuremgmt.FlagKubernetesDashboards,
},
expected: []schema.GroupVersion{
v1alpha1.DashboardResourceInfo.GroupVersion(),
v0alpha1.DashboardResourceInfo.GroupVersion(),
v2alpha1.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv2.DashboardResourceInfo.GroupVersion(),
},
},
{
@@ -195,9 +195,9 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
featuremgmt.FlagDashboardNewLayouts,
},
expected: []schema.GroupVersion{
v2alpha1.DashboardResourceInfo.GroupVersion(),
v0alpha1.DashboardResourceInfo.GroupVersion(),
v1alpha1.DashboardResourceInfo.GroupVersion(),
dashv2.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
},
},
}
+3 -3
View File
@@ -23,7 +23,7 @@ import (
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"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/services/apiserver/builder"
"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)
// Currently a search query is across folders and dashboards
if err == nil {
federate, err = asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
federate, err = asResourceKey(user.GetNamespace(), folders.RESOURCE)
}
case 1:
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
folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -261,7 +261,7 @@ func TestMultiTenantAuthorizer(t *testing.T) {
authz := newMultiTenantAuthorizer(tt.input.client)
authorized, _, err := authz.Authorize(
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 {
+6 -6
View File
@@ -9,7 +9,7 @@ import (
claims "github.com/grafana/authlib/types"
"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"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/folder"
@@ -40,20 +40,20 @@ func LegacyCreateCommandToUnstructured(cmd *folder.CreateFolderCommand) (*unstru
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)
}
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) {
f := &v0alpha1.Folder{
TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(),
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*folders.Folder, error) {
f := &folders.Folder{
TypeMeta: folders.FolderResourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
Name: v.UID,
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
CreationTimestamp: metav1.NewTime(v.Created),
Namespace: namespacer(v.OrgID),
},
Spec: v0alpha1.Spec{
Spec: folders.Spec{
Title: v.Title,
Description: v.Description,
},
+2 -2
View File
@@ -12,7 +12,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -91,7 +91,7 @@ func (s *folderStorage) Create(ctx context.Context,
return nil, err
}
p, ok := obj.(*v0alpha1.Folder)
p, ok := obj.(*folders.Folder)
if !ok {
return nil, fmt.Errorf("expected folder?")
}
@@ -5,7 +5,7 @@ import (
"testing"
"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"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/user"
@@ -50,7 +50,7 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) {
store: &fakeStorage{},
cfg: cfg,
}
obj := &v0alpha1.Folder{}
obj := &folders.Folder{}
ctx := request.WithNamespace(context.Background(), "org-2")
ctx = identity.WithRequester(ctx, &user.SignedInUser{
+6 -6
View File
@@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -116,7 +116,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
return nil, err
}
list := &v0alpha1.FolderList{}
list := &folders.FolderList{}
for _, v := range hits {
r, err := convertToK8sResource(v, s.namespacer)
if err != nil {
@@ -178,7 +178,7 @@ func (s *legacyStorage) Create(ctx context.Context,
return nil, err
}
p, ok := obj.(*v0alpha1.Folder)
p, ok := obj.(*folders.Folder)
if !ok {
return nil, fmt.Errorf("expected folder?")
}
@@ -248,11 +248,11 @@ func (s *legacyStorage) Update(ctx context.Context,
if err != nil {
return oldObj, created, err
}
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folders.Folder)
if !ok {
return nil, created, fmt.Errorf("expected folder after update")
}
old, ok := oldObj.(*v0alpha1.Folder)
old, ok := oldObj.(*folders.Folder)
if !ok {
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 {
return nil, false, err
}
p, ok := v.(*v0alpha1.Folder)
p, ok := v.(*folders.Folder)
if !ok {
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/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/foldertest"
"github.com/grafana/grafana/pkg/services/org"
@@ -57,7 +57,7 @@ func TestLegacyStorageList(t *testing.T) {
uidsReturnedByList := []string{}
for _, obj := range list {
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folderv1.Folder)
require.Equal(t, true, ok)
uidsReturnedByList = append(uidsReturnedByList, f.Name)
}
@@ -89,7 +89,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
result, err := storage.List(ctx, options)
require.NoError(t, err)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
token, err := base64.StdEncoding.DecodeString(list.Continue)
require.NoError(t, err)
@@ -113,7 +113,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
result, err := storage.List(ctx, options)
require.NoError(t, err)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
token, err := base64.StdEncoding.DecodeString(list.Continue)
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.WithFullpathUIDs)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
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.WithFullpathUIDs)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
require.Len(t, list.Items, 1)
+21 -28
View File
@@ -20,7 +20,7 @@ import (
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -37,7 +37,7 @@ import (
var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
var _ builder.APIGroupValidation = (*FolderAPIBuilder)(nil)
var resourceInfo = v0alpha1.FolderResourceInfo
var resourceInfo = folders.FolderResourceInfo
var errNoUser = errors.New("valid user is required")
var errNoResource = errors.New("resource name is required")
@@ -67,13 +67,6 @@ func RegisterAPIService(cfg *setting.Cfg,
registerer prometheus.Registerer,
unified resource.ResourceClient,
) *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{
gv: resourceInfo.GroupVersion(),
features: features,
@@ -103,11 +96,11 @@ func (b *FolderAPIBuilder) GetGroupVersion() schema.GroupVersion {
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&v0alpha1.Folder{},
&v0alpha1.FolderList{},
&v0alpha1.FolderInfoList{},
&v0alpha1.DescendantCounts{},
&v0alpha1.FolderAccessInfo{},
&folders.Folder{},
&folders.FolderList{},
&folders.FolderInfoList{},
&folders.DescendantCounts{},
&folders.FolderAccessInfo{},
)
}
@@ -142,7 +135,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
return err
}
storage[resourceInfo.StoragePath()] = store
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
apiGroupInfo.VersionedResourcesStorageMap[folders.VERSION] = storage
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
return nil
}
@@ -187,13 +180,13 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher}
storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc}
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
apiGroupInfo.VersionedResourcesStorageMap[folders.VERSION] = storage
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
return nil
}
func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return v0alpha1.GetOpenAPIDefinitions
return folders.GetOpenAPIDefinitions
}
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()
if verb == admission.Create || verb == admission.Update {
obj := a.GetObject()
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folders.Folder)
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, "")
return nil
@@ -239,9 +232,9 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
return nil // This is normal for sub-resource
}
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folders.Folder)
if !ok {
return fmt.Errorf("obj is not v0alpha1.Folder")
return fmt.Errorf("obj is not folders.Folder")
}
verb := a.GetOperation()
@@ -262,7 +255,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
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})
if err != nil {
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 {
return fmt.Errorf("obj is not v0alpha1.Folder")
return fmt.Errorf("obj is not folders.Folder")
}
if f.Spec.Title == "" {
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 {
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folders.Folder)
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 {
return fmt.Errorf("obj is not v0alpha1.Folder")
return fmt.Errorf("obj is not folders.Folder")
}
var newParent = getParent(obj)
if newParent != getParent(fOld) {
+67 -67
View File
@@ -10,7 +10,7 @@ import (
"k8s.io/apiserver/pkg/admission"
"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"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -22,7 +22,7 @@ import (
func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
type input struct {
obj *v0alpha1.Folder
obj *folders.Folder
annotations map[string]string
name string
}
@@ -30,15 +30,15 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
initialMaxDepth := folderValidationRules.maxDepth
folderValidationRules.maxDepth = 2
defer func() { folderValidationRules.maxDepth = initialMaxDepth }()
deepFolder := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
deepFolder := &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
}
deepFolder.Name = "valid-parent"
deepFolder.Annotations = map[string]string{"grafana.app/folder": "valid-grandparent"}
parentFolder := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
parentFolder := &folders.Folder{
Spec: folders.Spec{
Title: "foo-grandparent",
},
}
@@ -53,8 +53,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
{
name: "should return error when name is invalid",
input: input{
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
},
@@ -65,8 +65,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
{
name: "should return no error if every validation passes",
input: input{
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj: &folders.Folder{
Spec: folders.Spec{
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",
input: input{
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
},
@@ -97,8 +97,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
{
name: "should return error when title is empty",
input: input{
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj: &folders.Folder{
Spec: folders.Spec{
Title: "",
},
},
@@ -110,8 +110,8 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
name: "should return error if folder is a parent of itself",
input: input{
annotations: map[string]string{utils.AnnoKeyFolder: "myself"},
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj: &folders.Folder{
Spec: folders.Spec{
Title: "title",
},
},
@@ -145,10 +145,10 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.input.obj,
nil,
v0alpha1.SchemeGroupVersion.WithKind("folder"),
folders.SchemeGroupVersion.WithKind("folder"),
"stacks-123",
tt.input.name,
v0alpha1.SchemeGroupVersion.WithResource("folders"),
folders.SchemeGroupVersion.WithResource("folders"),
"",
"CREATE",
nil,
@@ -188,8 +188,8 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
us := storageMock{m, s}
sm := searcherMock{Mock: m}
obj := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj := &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
ObjectMeta: metav1.ObjectMeta{
@@ -221,10 +221,10 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
obj,
nil,
v0alpha1.SchemeGroupVersion.WithKind("folder"),
folders.SchemeGroupVersion.WithKind("folder"),
obj.Namespace,
obj.Name,
v0alpha1.SchemeGroupVersion.WithResource("folders"),
folders.SchemeGroupVersion.WithResource("folders"),
"",
"DELETE",
nil,
@@ -243,7 +243,7 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
}
func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
var circularObj = &v0alpha1.Folder{
var circularObj = &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Namespace: "stacks-123",
Name: "new-parent",
@@ -253,15 +253,15 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
tests := []struct {
name string
updatedObj *v0alpha1.Folder
expected *v0alpha1.Folder
updatedObj *folders.Folder
expected *folders.Folder
setupFn func(*mock.Mock)
wantErr bool
}{
{
name: "should allow updating a folder spec",
updatedObj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
updatedObj: &folders.Folder{
Spec: folders.Spec{
Title: "different title",
},
ObjectMeta: metav1.ObjectMeta{
@@ -270,8 +270,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
},
},
expected: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
expected: &folders.Folder{
Spec: folders.Spec{
Title: "different title",
},
ObjectMeta: metav1.ObjectMeta{
@@ -283,8 +283,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
},
{
name: "updated title should not be empty",
updatedObj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
updatedObj: &folders.Folder{
Spec: folders.Spec{
Title: "",
},
ObjectMeta: metav1.ObjectMeta{
@@ -297,8 +297,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
},
{
name: "should allow moving to a valid parent",
updatedObj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
updatedObj: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
ObjectMeta: metav1.ObjectMeta{
@@ -309,14 +309,14 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
},
setupFn: func(m *mock.Mock) {
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
&v0alpha1.Folder{},
&folders.Folder{},
nil).Once()
},
},
{
name: "should not allow moving to a k6 folder",
updatedObj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
updatedObj: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
ObjectMeta: metav1.ObjectMeta{
@@ -327,15 +327,15 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
},
setupFn: func(m *mock.Mock) {
m.On("Get", mock.Anything, accesscontrol.K6FolderUID, mock.Anything).Return(
&v0alpha1.Folder{},
&folders.Folder{},
nil).Once()
},
wantErr: true,
},
{
name: "should not allow moving to a folder that is too deep",
updatedObj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
updatedObj: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
ObjectMeta: metav1.ObjectMeta{
@@ -358,8 +358,8 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
us := storageMock{m, s}
sm := searcherMock{Mock: m}
obj := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
obj := &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
ObjectMeta: metav1.ObjectMeta{
@@ -386,10 +386,10 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.updatedObj,
obj,
v0alpha1.SchemeGroupVersion.WithKind("folder"),
folders.SchemeGroupVersion.WithKind("folder"),
tt.updatedObj.Namespace,
tt.updatedObj.Name,
v0alpha1.SchemeGroupVersion.WithResource("folders"),
folders.SchemeGroupVersion.WithResource("folders"),
"",
"UPDATE",
nil,
@@ -410,14 +410,14 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
tests := []struct {
name string
input *v0alpha1.Folder
expected *v0alpha1.Folder
input *folders.Folder
expected *folders.Folder
wantErr bool
}{
{
name: "should trim a title",
input: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
input: &folders.Folder{
Spec: folders.Spec{
Title: " foo ",
},
TypeMeta: metav1.TypeMeta{
@@ -427,8 +427,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
Name: "valid-name",
},
},
expected: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
expected: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
TypeMeta: metav1.TypeMeta{
@@ -441,8 +441,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
},
{
name: "should return error if title doesnt exist",
input: &v0alpha1.Folder{
Spec: v0alpha1.Spec{},
input: &folders.Folder{
Spec: folders.Spec{},
TypeMeta: metav1.TypeMeta{
Kind: "Folder",
},
@@ -454,7 +454,7 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
},
{
name: "should return error if spec doesnt exist",
input: &v0alpha1.Folder{
input: &folders.Folder{
TypeMeta: metav1.TypeMeta{
Kind: "Folder",
},
@@ -482,10 +482,10 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.input,
nil,
v0alpha1.SchemeGroupVersion.WithKind("folder"),
folders.SchemeGroupVersion.WithKind("folder"),
"stacks-123",
tt.input.Name,
v0alpha1.SchemeGroupVersion.WithResource("folders"),
folders.SchemeGroupVersion.WithResource("folders"),
"",
"CREATE",
nil,
@@ -503,8 +503,8 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
}
func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
existingObj := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
existingObj := &folders.Folder{
Spec: folders.Spec{
Title: "some title",
},
TypeMeta: metav1.TypeMeta{
@@ -516,14 +516,14 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
}
tests := []struct {
name string
input *v0alpha1.Folder
expected *v0alpha1.Folder
input *folders.Folder
expected *folders.Folder
wantErr bool
}{
{
name: "should trim a title",
input: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
input: &folders.Folder{
Spec: folders.Spec{
Title: " foo ",
},
TypeMeta: metav1.TypeMeta{
@@ -533,8 +533,8 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
Name: "valid-name",
},
},
expected: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
expected: &folders.Folder{
Spec: folders.Spec{
Title: "foo",
},
TypeMeta: metav1.TypeMeta{
@@ -547,8 +547,8 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
},
{
name: "should return error if title doesnt exist",
input: &v0alpha1.Folder{
Spec: v0alpha1.Spec{},
input: &folders.Folder{
Spec: folders.Spec{},
TypeMeta: metav1.TypeMeta{
Kind: "Folder",
},
@@ -560,7 +560,7 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
},
{
name: "should return error if spec doesnt exist",
input: &v0alpha1.Folder{
input: &folders.Folder{
TypeMeta: metav1.TypeMeta{
Kind: "Folder",
},
@@ -588,10 +588,10 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.input,
existingObj,
v0alpha1.SchemeGroupVersion.WithKind("folder"),
folders.SchemeGroupVersion.WithKind("folder"),
"stacks-123",
tt.input.Name,
v0alpha1.SchemeGroupVersion.WithResource("folders"),
folders.SchemeGroupVersion.WithResource("folders"),
"",
"UPDATE",
nil,
+4 -4
View File
@@ -8,7 +8,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"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/folder"
"github.com/grafana/grafana/pkg/services/guardian"
@@ -22,7 +22,7 @@ var _ = rest.Connecter(&subAccessREST{})
var _ = rest.StorageMetadata(&subAccessREST{})
func (r *subAccessREST) New() runtime.Object {
return &v0alpha1.FolderAccessInfo{}
return &folders.FolderAccessInfo{}
}
func (r *subAccessREST) Destroy() {
@@ -37,7 +37,7 @@ func (r *subAccessREST) ProducesMIMETypes(verb string) []string {
}
func (r *subAccessREST) ProducesObject(verb string) interface{} {
return &v0alpha1.FolderAccessInfo{}
return &folders.FolderAccessInfo{}
}
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) {
access := &v0alpha1.FolderAccessInfo{}
access := &folders.FolderAccessInfo{}
access.CanEdit, _ = guardian.CanEdit()
access.CanSave, _ = guardian.CanSave()
access.CanAdmin, _ = guardian.CanAdmin()
+6 -6
View File
@@ -7,7 +7,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"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/storage/unified/resource"
)
@@ -22,7 +22,7 @@ var (
)
func (r *subCountREST) New() runtime.Object {
return &v0alpha1.DescendantCounts{}
return &folders.DescendantCounts{}
}
func (r *subCountREST) Destroy() {
@@ -37,7 +37,7 @@ func (r *subCountREST) ProducesMIMETypes(verb string) []string {
}
func (r *subCountREST) ProducesObject(verb string) interface{} {
return &v0alpha1.DescendantCounts{}
return &folders.DescendantCounts{}
}
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)
return
}
rsp := &v0alpha1.DescendantCounts{
Counts: make([]v0alpha1.ResourceStats, len(stats.Stats)),
rsp := &folders.DescendantCounts{
Counts: make([]folders.ResourceStats, len(stats.Stats)),
}
for i, v := range stats.Stats {
rsp.Counts[i] = v0alpha1.ResourceStats{
rsp.Counts[i] = folders.ResourceStats{
Group: v.Group,
Resource: v.Resource,
Count: v.Count,
+11 -11
View File
@@ -4,7 +4,7 @@ import (
"context"
"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"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -14,46 +14,46 @@ import (
func TestSubParent(t *testing.T) {
tests := []struct {
name string
input *v0alpha1.Folder
expected *v0alpha1.FolderInfoList
input *folders.Folder
expected *folders.FolderInfoList
setuFn func(*mock.Mock)
}{
{
name: "no parents",
input: &v0alpha1.Folder{
input: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{},
},
Spec: v0alpha1.Spec{
Spec: folders.Spec{
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",
input: &v0alpha1.Folder{
input: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{"grafana.app/folder": "parent-test"},
},
Spec: v0alpha1.Spec{
Spec: folders.Spec{
Title: "some tittle",
},
},
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{
Name: "parent-test",
Annotations: map[string]string{},
},
Spec: v0alpha1.Spec{
Spec: folders.Spec{
Title: "some other tittle",
},
}, nil).Once()
},
expected: &v0alpha1.FolderInfoList{Items: []v0alpha1.FolderInfo{
expected: &folders.FolderInfoList{Items: []folders.FolderInfo{
{Name: "test", Title: "some tittle", Parent: "parent-test"},
{Name: "parent-test", Title: "some other tittle"}},
}},
+11 -11
View File
@@ -10,7 +10,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"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 {
@@ -21,7 +21,7 @@ var _ = rest.Connecter(&subParentsREST{})
var _ = rest.StorageMetadata(&subParentsREST{})
func (r *subParentsREST) New() runtime.Object {
return &v0alpha1.FolderInfoList{}
return &folders.FolderInfoList{}
}
func (r *subParentsREST) Destroy() {
@@ -36,7 +36,7 @@ func (r *subParentsREST) ProducesMIMETypes(verb string) []string {
}
func (r *subParentsREST) ProducesObject(verb string) interface{} {
return &v0alpha1.FolderInfoList{}
return &folders.FolderInfoList{}
}
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 {
return nil, err
}
folder, ok := obj.(*v0alpha1.Folder)
folder, ok := obj.(*folders.Folder)
if !ok {
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
}
func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *v0alpha1.FolderInfoList {
info := &v0alpha1.FolderInfoList{
Items: []v0alpha1.FolderInfo{},
func (r *subParentsREST) parents(ctx context.Context, folder *folders.Folder) *folders.FolderInfoList {
info := &folders.FolderInfoList{
Items: []folders.FolderInfo{},
}
for folder != nil {
parent := getParent(folder)
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: folder.Name,
Title: folder.Spec.Title,
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{})
if err != nil {
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: parent,
Detached: true,
Description: err.Error(),
@@ -87,9 +87,9 @@ func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *
break
}
parentFolder, ok := obj.(*v0alpha1.Folder)
parentFolder, ok := obj.(*folders.Folder)
if !ok {
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: parent,
Detached: true,
Description: fmt.Sprintf("expected folder, found: %T", obj),
@@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
"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"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"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
progress.SetMessage(ctx, "loading legacy folders")
progress.SetMessage(ctx, "loading folders from SQL")
reader := NewLegacyFolderReader(w.legacyMigrator, rw.Config().Name, namespace)
if err = reader.Read(ctx, w.legacyMigrator, rw.Config().Name, namespace); err != nil {
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())
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 {
result := jobs.JobResourceResult{
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)
}
progress.SetMessage(ctx, "exporting legacy resources")
progress.SetMessage(ctx, "exporting resources from SQL")
resourceManager := resources.NewResourcesManager(rw, folders, parser, clients, userInfo)
for _, kind := range resources.SupportedProvisioningResources {
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 |
|----------|---------|
| ![Before]({{.GrafanaScreenshotURL}}) | ![Preview]({{.PreviewScreenshotURL}}) |
{{- else if .GrafanaScreenshotURL}}
### Original of {{.Title}}
![Original]({{.GrafanaScreenshotURL}})
{{- else if .PreviewScreenshotURL}}
### Preview of {{.Parsed.Info.Path}}
![Preview]({{.PreviewScreenshotURL}})
{{ 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