diff --git a/.betterer.results b/.betterer.results index dcb22c40562..917b0d0f1f2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1530,6 +1530,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], + "public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b8a8840994b..050e18e7895 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -823,8 +823,8 @@ embed.go @grafana/grafana-as-code /.github/workflows/pr-frontend-unit-tests.yml @grafana/grafana-frontend-platform /.github/workflows/frontend-lint.yml @grafana/grafana-frontend-platform /.github/workflows/analytics-events-report.yml @grafana/grafana-frontend-platform -/.github/workflows/e2e-suite-various.yml @grafana/grafana-developer-enablement-squad /.github/workflows/pr-e2e-tests.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/run-e2e-suite.yml @grafana/grafana-developer-enablement-squad # Generated files not requiring owner approval /packages/grafana-data/src/types/featureToggles.gen.ts @grafanabot diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index c18f156c39a..cb57210abb0 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -39,10 +39,33 @@ jobs: retention-days: 1 name: ${{ steps.artifact.outputs.artifact }} path: grafana.tar.gz - misc-suite: + e2e-matrix: + name: ${{ matrix.suite }} + strategy: + matrix: + suite: + - various-suite + - dashboards-suite + - smoke-tests-suite + - panels-suite needs: - build-grafana - uses: ./.github/workflows/e2e-suite-various.yml - name: Various Suite + uses: ./.github/workflows/run-e2e-suite.yml with: package: ${{ needs.build-grafana.outputs.artifact }} + suite: ${{ matrix.suite }} + e2e-matrix-old-arch: + name: ${{ matrix.suite }} (old arch) + strategy: + matrix: + suite: + - old-arch/various-suite + - old-arch/dashboards-suite + - old-arch/smoke-tests-suite + - old-arch/panels-suite + needs: + - build-grafana + uses: ./.github/workflows/run-e2e-suite.yml + with: + package: ${{ needs.build-grafana.outputs.artifact }} + suite: ${{ matrix.suite }} diff --git a/.github/workflows/e2e-suite-various.yml b/.github/workflows/run-e2e-suite.yml similarity index 61% rename from .github/workflows/e2e-suite-various.yml rename to .github/workflows/run-e2e-suite.yml index ff7a69cfe48..8c3c5851325 100644 --- a/.github/workflows/e2e-suite-various.yml +++ b/.github/workflows/run-e2e-suite.yml @@ -1,4 +1,4 @@ -name: suites / various +name: e2e suite on: workflow_call: @@ -6,6 +6,9 @@ on: package: type: string required: true + suite: + type: string + required: true jobs: main: @@ -16,12 +19,14 @@ jobs: with: name: ${{ inputs.package }} - uses: dagger/dagger-for-github@8.0.0 + if: inputs.old-arch == false with: verb: run - args: go run ./pkg/build/e2e --package=grafana.tar.gz --suite=various-suite + args: go run ./pkg/build/e2e --package=grafana.tar.gz --suite=${{ inputs.suite }} + - run: echo "suite=$(echo ${{ inputs.suite }} | sed 's/\//-/g')" >> $GITHUB_ENV - uses: actions/upload-artifact@v4 - if: always() + if: ${{ always() && inputs.old-arch != true }} with: - name: e2e-various-${{github.run_number}} + name: e2e-${{ env.suite }}-${{github.run_number}} path: videos retention-days: 1 diff --git a/apps/advisor/pkg/app/checkregistry/checkregistry.go b/apps/advisor/pkg/app/checkregistry/checkregistry.go index 31be8ae936e..5b07b827cdd 100644 --- a/apps/advisor/pkg/app/checkregistry/checkregistry.go +++ b/apps/advisor/pkg/app/checkregistry/checkregistry.go @@ -53,6 +53,7 @@ func (s *Service) Checks() []checks.Check { s.pluginStore, s.pluginContextProvider, s.pluginClient, + s.pluginRepo, ), plugincheck.New( s.pluginStore, diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index edb37410299..92a325f3211 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -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) } diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go index 09a7487bd8b..7c538da83f7 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go @@ -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) - 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) - } - } - } - + failures, err := runChecks(check) 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) - 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) - } - } - } - + failures, err := runChecks(check) assert.NoError(t, err) - assert.Equal(t, 1, len(items)) assert.Len(t, failures, 1) assert.Equal(t, "health-check", failures[0].StepID) }) @@ -130,66 +127,98 @@ 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.Empty(t, failures) + }) + + t.Run("should return failure when plugin is not installed", 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{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"), + } + + failures, err := runChecks(check) assert.NoError(t, err) - assert.Equal(t, 1, len(items)) - assert.Len(t, failures, 0) + assert.Len(t, failures, 1) + assert.Equal(t, "health-check", failures[0].StepID) }) - t.Run("should return failure when plugin is not installed", func(t *testing.T) { + 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{err: plugins.ErrPluginNotRegistered} + 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"), } - 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.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.Equal(t, 1, len(items)) assert.Len(t, failures, 1) - assert.Equal(t, "health-check", failures[0].StepID) + assert.Equal(t, MissingPluginStepID, failures[0].StepID) + assert.Len(t, failures[0].Links, 1) }) } @@ -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 +} diff --git a/apps/dashboard/kinds/dashboard.cue b/apps/dashboard/kinds/dashboard.cue index 2e56b8e2e22..5be84dcccf3 100644 --- a/apps/dashboard/kinds/dashboard.cue +++ b/apps/dashboard/kinds/dashboard.cue @@ -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 } } diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 5b52aef5a6e..7ac0428493b 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -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: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 6b5511a751d..f104c3fdf45 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -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 { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 0bed9c70b5c..e2f3b302f99 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -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"}, } } diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index 7733659d606..cb11e024f85 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -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", }, diff --git a/apps/dashboard/pkg/migration/conversion/conversion_test.go b/apps/dashboard/pkg/migration/conversion/conversion_test.go index c77afd78e1f..243745ce0ab 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_test.go @@ -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") diff --git a/conf/defaults.ini b/conf/defaults.ini index 6997156c517..e5b1f010c8f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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. @@ -1064,8 +1067,8 @@ user_identity_federated_credential_audience = username_assertion = # Set the plugins that will receive Azure settings for each request (via plugin context) -# By default this will include all Grafana Labs owned Azure plugins, or those that make use of Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL). -forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql +# By default this will include all Grafana Labs owned Azure plugins, or those that make use of Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL, Azure Prometheus). +forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql, grafana-azureprometheus-datasource # Specifies whether Entra password auth can be used for the MSSQL data source # Disabled by default, needs to be explicitly enabled diff --git a/conf/provisioning/sample/dashboard-v2.json b/conf/provisioning/sample/dashboard-v2.json new file mode 100644 index 00000000000..ad4671943c3 --- /dev/null +++ b/conf/provisioning/sample/dashboard-v2.json @@ -0,0 +1,246 @@ +{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": { + "name": "sample-dash-v2" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts" + } + } + ], + "cursorSync": "Off", + "description": "", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 1, + "links": [], + "title": "Simle timeseries", + "vizConfig": { + "kind": "timeseries", + "spec": { + "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": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.0-pre" + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 2, + "links": [], + "title": "Simple stat", + "vizConfig": { + "kind": "stat", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.0-pre" + } + } + } + } + }, + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "", + "autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "fiscalYearStartMonth": 0, + "from": "now-6h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "v2alpha1 dashboard", + "variables": [] + } +} diff --git a/conf/sample.ini b/conf/sample.ini index e23dd0631c5..39afdec6fea 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1048,7 +1048,7 @@ # Set the plugins that will receive Azure settings for each request (via plugin context) # By default this will include all Grafana Labs owned Azure plugins, or those that make use of Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL). -;forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql +;forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql, grafana-azureprometheus-datasourc # Specifies whether Entra password auth can be used for the MSSQL data source # Disabled by default, needs to be explicitly enabled diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index 73a6621b68a..7d63d6067dd 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -236,6 +236,10 @@ To do this, you need to make sure that your alert rule is in the right evaluatio Pausing stops alert rule evaluation and doesn't create any alert instances. This is different to [mute timings](ref:mute-timings), which stop notifications from being delivered, but still allows for alert rule evaluation and the creation of alert instances. +1. Set the time threshold for alerts firing. + +You can set the minimum amount of time that an alert remains firing after the breached threshold expression no longer returns any results. This sets an alert to a "Recovering" state for the duration of time set here. The Recovering state can be used to reduce noise from flapping alerts. Select "none" stop an alert from firing immediately after the breach threshold is cleared. + 1. In **Configure no data and error handling**, you can define the alerting behavior and alerting state for two scenarios: - When the evaluation returns **No data** or all values are null. @@ -280,9 +284,13 @@ Complete the following steps to set up notifications. {{< docs/shared lookup="alerts/configure-notification-message.md" source="grafana" version="" >}} -## Restore deleted alert rules +## Permanently delete or restore deleted alert rules -Deleted alert rules are stored for 30 days. Admins can restore deleted Grafana-managed alert rules. +Only users with an Admin role can restore deleted Grafana-managed alert rules. After an alert rule is restored, it is restored with a new, different UID from the one it had before. 1. Go to **Alerts & IRM > Alerting > Recently deleted**. -1. Click the **Restore** button to restore the alert rule. +1. Click the **Restore** button to restore the alert rule or click **Delete permanently** to delete the alert rule. + +{{< admonition type="note" >}} +Deleted alert rules are stored for 30 days. Grafana Enterprise and OSS users can adjust the length of time for which the rules are stored can be adjusted in the Grafana configuration file's `[unified_alerting].deleted_rule_retention` field. For an example of how to modify the Grafana configuration file, refer to the [documentation example here](/docs/grafana/latest/alerting/set-up/configure-alert-state-history/#configuring-grafana). +{{< /admonition >}} diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md index bf4e0cc90fe..898b2ae98b5 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md @@ -33,6 +33,11 @@ refs: destination: /docs/grafana//alerting/configure-notifications/manage-contact-points/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/ + notification-templates-namespaced-functions: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/configure-notifications/template-notifications/reference/#namespaced-functions + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/reference/#namespaced-functions --- # Configure webhook notifications @@ -70,6 +75,7 @@ For more details on contact points, including how to test them and enable notifi | Basic Authentication Password | Password for HTTP Basic Authentication. | | Authentication Header Scheme | Scheme for the `Authorization` Request Header. Default is `Bearer`. | | Authentication Header Credentials | Credentials for the `Authorization` Request header. | +| Extra Headers | Additional HTTP headers to include in the request. | | Max Alerts | Maximum number of alerts to include in a notification. Any alerts exceeding this limit are ignored. `0` means no limit. | | TLS | TLS configuration options, including CA certificate, client certificate, and client key. | | HMAC Signature | HMAC signature configuration options. | @@ -115,18 +121,11 @@ To validate incoming webhook requests from Grafana, follow these steps: Use the following settings to include custom data within the [JSON payload](#body). Both options support using [notification templates](ref:notification-templates). -| Option | Description | -| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| Title | Sends the value as a string in the `title` field of the [JSON payload](#body). Supports [notification templates](ref:notification-templates). | -| Message | Sends the value as a string in the `message` field of the [JSON payload](#body). Supports [notification templates](ref:notification-templates). | - -{{< admonition type="note" >}} -You can customize the `title` and `message` options to include custom messages and notification data using notification templates. These fields are always sent as strings in the JSON payload. - -However, you cannot customize the webhook data structure, such as adding or changing other JSON fields and HTTP headers, or sending data in a different format like XML. - -If you need to format these fields as JSON or modify other webhook request options, consider sending webhook notifications to a proxy server that adjusts the webhook request before forwarding it to the final destination. -{{< /admonition >}} +| Option | Description | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Title | Sends the value as a string in the `title` field of the [JSON payload](#body). Supports [notification templates](ref:notification-templates). | +| Message | Sends the value as a string in the `message` field of the [JSON payload](#body). Supports [notification templates](ref:notification-templates). | +| [Custom Payload](#custom-payload) | Optionally override the default payload format with a custom template. | #### Optional notification settings @@ -134,7 +133,7 @@ If you need to format these fields as JSON or modify other webhook request optio | ------------------------ | ------------------------------------------------------------------- | | Disable resolved message | Enable this option to prevent notifications when an alert resolves. | -## JSON payload +## Default JSON payload The following example shows the payload of a webhook notification containing information about two firing alerts: @@ -252,3 +251,71 @@ The Alert object represents an alert included in the notification group, as prov | `dashboardURL` | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation. | | `panelURL` | string | A link to the panel if the alert has a Panel ID annotation. | | `imageURL` | string | URL of a screenshot of a panel assigned to the rule that created this notification. | + +## Custom Payload + +The `Custom Payload` option allows you to completely customize the webhook payload using templates. This gives you full control over the structure and content of the webhook request. + +| Option | Description | +| ----------------- | --------------------------------------------------------------------------------------------------------- | +| Payload Template | Template string that defines the structure of the webhook payload. | +| Payload Variables | Key-value pairs that define additional variables available in the template under `.Vars.`. | + +Example of a custom payload template that includes variables: + +``` +{ + "alert_name": "{{ .CommonLabels.alertname }}", + "status": "{{ .Status }}", + "environment": "{{ .Vars.environment }}", + "custom_field": "{{ .Vars.custom_field }}" +} +``` + +{{< admonition type="note" >}} +When using Custom Payload, the Title and Message fields are ignored as the entire payload structure is determined by your template. +{{< /admonition >}} + +### JSON Template Functions + +When creating custom payloads, several template functions are available to help generate valid JSON structures. These include functions for creating dictionaries (`coll.Dict`), arrays (`coll.Slice`, `coll.Append`), and converting between JSON strings and objects (`data.ToJSON`, `data.JSON`). + +For detailed information about these and other template functions, refer to [notification template functions](ref:notification-templates-namespaced-functions). + +Example using JSON helper functions: + +``` +{{ define "webhook.custom.payload" -}} + {{ coll.Dict + "receiver" .Receiver + "status" .Status + "alerts" (tmpl.Exec "webhook.custom.simple_alerts" .Alerts | data.JSON) + "groupLabels" .GroupLabels + "commonLabels" .CommonLabels + "commonAnnotations" .CommonAnnotations + "externalURL" .ExternalURL + "version" "1" + "orgId" (index .Alerts 0).OrgID + "truncatedAlerts" .TruncatedAlerts + "groupKey" .GroupKey + "state" (tmpl.Inline "{{ if eq .Status \"resolved\" }}ok{{ else }}alerting{{ end }}" . ) + "allVariables" .Vars + "title" (tmpl.Exec "default.title" . ) + "message" (tmpl.Exec "default.message" . ) + | data.ToJSONPretty " "}} +{{- end }} + +{{- /* Embed json templates in other json templates. */ -}} +{{ define "webhook.custom.simple_alerts" -}} + {{- $alerts := coll.Slice -}} + {{- range . -}} + {{ $alerts = coll.Append (coll.Dict + "status" .Status + "labels" .Labels + "startsAt" .StartsAt + "endsAt" .EndsAt + ) $alerts}} + {{- end -}} + {{- $alerts | data.ToJSON -}} +{{- end }} +``` diff --git a/docs/sources/alerting/configure-notifications/template-notifications/reference.md b/docs/sources/alerting/configure-notifications/template-notifications/reference.md index 905ad86e264..665e15857c9 100644 --- a/docs/sources/alerting/configure-notifications/template-notifications/reference.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/reference.md @@ -56,17 +56,19 @@ This documentation lists the data available for use in notification templates. In notification templates, dot (`.`) is initialized with the following data: -| Name | Type | Description | -| ------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | -| `Receiver` | string | The name of the contact point sending the notification | -| `Status` | string | The status is `firing` if at least one alert is firing, otherwise `resolved`. | -| `Alerts` | [][Alert](#alert) | List of all firing and resolved alerts in this notification. | -| `Alerts.Firing` | [][Alert](#alert) | List of all firing alerts in this notification. | -| `Alerts.Resolved` | [][Alert](#alert) | List of all resolved alerts in this notification. | -| `GroupLabels` | [KV](#kv) | The labels that group these alerts in this notification based on the `Group by` option. | -| `CommonLabels` | [KV](#kv) | The labels common to all alerts in this notification. | -| `CommonAnnotations` | [KV](#kv) | The annotations common to all alerts in this notification. | -| `ExternalURL` | string | A link to Grafana, or the Alertmanager that sent this notification if using an external Alertmanager. | +| Name | Type | Description | +| ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- | +| `Receiver` | string | The name of the contact point sending the notification | +| `Status` | string | The status is `firing` if at least one alert is firing, otherwise `resolved`. | +| `Alerts` | [][Alert](#alert) | List of all firing and resolved alerts in this notification. | +| `Alerts.Firing` | [][Alert](#alert) | List of all firing alerts in this notification. | +| `Alerts.Resolved` | [][Alert](#alert) | List of all resolved alerts in this notification. | +| `GroupLabels` | [KV](#kv) | The labels that group these alerts in this notification based on the `Group by` option. | +| `CommonLabels` | [KV](#kv) | The labels common to all alerts in this notification. | +| `CommonAnnotations` | [KV](#kv) | The annotations common to all alerts in this notification. | +| `ExternalURL` | string | A link to Grafana, or the Alertmanager that sent this notification if using an external Alertmanager. | +| `GroupKey` | string | The key used to identify this alert group. | +| `TruncatedAlerts` | integer | The number of alerts, if any, that were truncated in the notification. Supported by Webhook and OnCall. | It's important to remember that [a single notification can group multiple alerts](ref:alert-grouping) to reduce the number of alerts you receive. `Alerts` is an array that includes all the alerts in the notification. @@ -115,6 +117,7 @@ Grafana-managed alerts include these additional properties: | `SilenceURL` | string | A link to silence the alert. | | `Values` | [KV](#kv) | The values of expressions used to evaluate the alert condition. Only relevant values are included. | | `ValueString` | string | A string that contains the labels and value of each reduced expression in the alert. | +| `OrgID` | integer | The ID of the organization that owns the alert. | This example iterates over the list of firing and resolved alerts (`.Alerts`) in the notification and prints the data for each alert: @@ -264,6 +267,176 @@ You can then use `tz` to change the timezone from UTC to local time, such as `Eu 21:01:45 CET ``` +## Namespaced Functions + +In addition to the top-level functions, the following namespaced functions are also available: + +### Collection Functions + +| Name | Arguments | Returns | Description | +| ------------- | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `coll.Dict` | key string, value any, ... | map | Creates a map with string keys from key/value pairs. All keys are converted to strings. If an odd number of arguments is provided, the last key will have an empty string value. | +| `coll.Slice` | ...any | []any | Creates a slice (array/list) from the provided arguments. Useful for creating lists that can be iterated over with `range`. | +| `coll.Append` | value any, list []any | []any | Creates a new list by appending a value to the end of an existing list. Does not modify the original list. | + +Example using collection functions: + +```go +{{ define "collection.example" }} +{{- /* Create a dictionary of alert metadata */ -}} +{{- $metadata := coll.Dict + "severity" "critical" + "team" "infrastructure" + "environment" "production" +-}} + +{{- /* Create a slice of affected services */ -}} +{{- $services := coll.Slice "database" "cache" "api" -}} + +{{- /* Append a new service to the list */ -}} +{{- $services = coll.Append "web" $services -}} + +{{- /* Use the collections in a template */ -}} +Affected Services: {{ range $services }}{{ . }},{{ end }} + +Alert Metadata: +{{- range $k, $v := $metadata }} + {{ $k }}: {{ $v }} +{{- end }} +{{ end }} +``` + +Output: + +``` +Affected Services: database,cache,api,web, + +Alert Metadata: + environment: production + severity: critical + team: infrastructure +``` + +### Data Functions + +| Name | Arguments | Returns | Description | +| ------------------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `data.JSON` | jsonString string | any | Parses a JSON string into an object that can be manipulated in the template. Works with both JSON objects and arrays. | +| `data.ToJSON` | obj any | string | Serializes any object (maps, arrays, etc.) into a JSON string. Useful for creating webhook payloads. | +| `data.ToJSONPretty` | indent string, obj any | string | Creates an indented JSON string representation of an object. The first argument specifies the indentation string (e.g., spaces). | + +Example using data functions: + +```go +{{ define "data.example" }} +{{- /* First, let's create some alert data as a JSON string */ -}} +{{ $jsonString := `{ + "service": { + "name": "payment-api", + "environment": "production", + "thresholds": { + "error_rate": 5, + "latency_ms": 100 + } + } +}` }} + +{{- /* Parse the JSON string into an object we can work with */ -}} +{{ $config := $jsonString | data.JSON }} + +{{- /* Create a new alert payload */ -}} +{{ $payload := coll.Dict + "service" $config.service.name + "environment" $config.service.environment + "status" .Status + "errorThreshold" $config.service.thresholds.error_rate +}} + +{{- /* Output the payload in different JSON formats */ -}} +Compact JSON: {{ $payload | data.ToJSON }} + +Pretty JSON with 2-space indent: +{{ $payload | data.ToJSONPretty " " }} +{{ end }} +``` + +Output: + +``` +Compact JSON: {"environment":"production","errorThreshold":5,"service":"payment-api","status":"resolved"} + +Pretty JSON with 2-space indent: +{ + "environment": "production", + "errorThreshold": 5, + "service": "payment-api", + "status": "resolved" +} +``` + +### Template Functions + +| Name | Arguments | Returns | Description | +| ------------- | ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `tmpl.Exec` | name string, [context any] | string | Executes a named template and returns the result as a string. Similar to the `template` action but allows for post-processing of the result. | +| `tmpl.Inline` | template string, context any | string | Renders a string as a template. | + +```go +{{ define "template.example" -}} +{{ coll.Dict + "info" (tmpl.Exec `info` . | data.JSON) + "severity" (tmpl.Inline `{{ print "critical" | toUpper }}` . ) + | data.ToJSONPretty " "}} +{{- end }} + +{{- /* Define a sub-template */ -}} +{{ define "info" -}} +{{coll.Dict + "team" "infrastructure" + "environment" "production" | data.ToJSON }} +{{- end }} +``` + +Output: + +```json +{ + "info": { + "environment": "production", + "team": "infrastructure" + }, + "severity": "CRITICAL" +} +``` + +### Time Functions + +| Name | Arguments | Returns | Description | +| ---------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------ | +| `time.Now` | | Time | Returns the current local time as a time.Time object. Can be formatted using Go's time formatting functions. | + +Example using time functions: + +```go +{{ define "time.example" }} +{{- /* Get current time in different formats */ -}} +Current Time (UTC): {{ (time.Now).UTC.Format "2006-01-02 15:04:05 MST" }} +Current Time (Local): {{ (time.Now).Format "Monday, January 2, 2006 at 15:04:05" }} + +{{- /* Compare alert time with current time */ -}} +{{ $timeAgo := (time.Now).Sub .StartsAt }} +Alert fired: {{ $timeAgo }} ago +{{ end }} +``` + +Output: + +``` +Current Time (UTC): 2025-03-08 18:14:27 UTC +Current Time (Local): Saturday, March 8, 2025 at 14:14:27 +Alert fired: 25h49m32.78574723s ago +``` + ## Differences with annotation and label templates In the alert rule, you can also template annotations and labels to include additional information. For example, you might add a `summary` annotation that displays the query value triggering the alert. diff --git a/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md b/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md index 42b3faa03ee..bc610c4457f 100644 --- a/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md @@ -29,7 +29,7 @@ The criteria determining when an alert rule fires are based on two settings: - [Evaluation group](#evaluation-group): how frequently the alert rule is evaluated. - [Pending period](#pending-period): how long the condition must be met to start firing. -{{< figure src="/media/docs/alerting/alert-rule-evaluation.png" max-width="750px" alt="Set the evaluation behavior of the alert rule in Grafana." caption="Set alert rule evaluation" >}} +{{< figure src="/media/docs/alerting/alert-rule-evaluation-2.png" max-width="750px" alt="Set the evaluation behavior of the alert rule in Grafana." caption="Set alert rule evaluation" >}} ## Evaluation group @@ -53,6 +53,12 @@ The pending period specifies how long the condition must be met before firing, e You can also set the pending period to zero to skip it and have the alert fire immediately once the condition is met. +## Keep firing for + +You can set a period to keep an alert firing after the threshold is no longer breached. This sets the alert to a Recovering state. In a Recovering state, the alert won’t fire again if the threshold is breached. The Keep firing timer is then reset and the alert transitions back to Alerting state. + +The Keep firing for period helps reduce repeated firing-resolving-firing notification scenarios caused by flapping alerts. + ## Evaluation example Keep in mind: diff --git a/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md b/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md index 3cfbc85c31e..b7987e29828 100644 --- a/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md +++ b/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md @@ -29,6 +29,11 @@ refs: destination: /docs/grafana//alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling + keep-firing: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rule-evaluation/#keep-firing-for + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rule-evaluation/#keep-firing-for notifications: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/fundamentals/notifications/ @@ -49,12 +54,13 @@ An alert instance can be in either of the following states: | **Normal** | The state of an alert when the condition (threshold) is not met. | | **Pending** | The state of an alert that has breached the threshold but for less than the [pending period](ref:pending-period). | | **Alerting** | The state of an alert that has breached the threshold for longer than the [pending period](ref:pending-period). | +| **Recovering** | The state of an alert that has been configured to keep [firing for a duration after it is triggered](ref:keep-firing). | | **No Data\*** | The state of an alert whose query returns no data or all values are null.
An alert in this state generates a new [DatasourceNoData alert](#no-data-and-error-alerts). You can [modify the default behavior of the no data state](#modify-the-no-data-or-error-state). | | **Error\*** | The state of an alert when an error or timeout occurred evaluating the alert rule.
An alert in this state generates a new [DatasourceError alert](#no-data-and-error-alerts). You can [modify the default behavior of the error state](#modify-the-no-data-or-error-state). | If an alert rule changes (except for updates to annotations, the evaluation interval, or other internal fields), its alert instances reset to the `Normal` state. The alert instance state then updates accordingly during the next evaluation. -{{< figure src="/media/docs/alerting/alert-instance-states-v3.png" caption="Alert instance state diagram" alt="A diagram of the distinct alert instance states and transitions." max-width="750px" >}} +{{< figure src="/media/docs/alerting/alert-state-diagram2.png" caption="Alert instance state diagram" alt="A diagram of the distinct alert instance states and transitions." max-width="750px" >}} {{< admonition type="note" >}} diff --git a/docs/sources/alerting/monitor-status/view-alert-rules.md b/docs/sources/alerting/monitor-status/view-alert-rules.md index f521800f5bb..199941c9578 100644 --- a/docs/sources/alerting/monitor-status/view-alert-rules.md +++ b/docs/sources/alerting/monitor-status/view-alert-rules.md @@ -61,7 +61,11 @@ For details on how rule states and alert instance states are displayed, refer to ## View, compare and restore alert rules versions. -You can view, compare, and restore previous alert rule versions. The number of alert rule versions is limited to a maximum of 10 alert rule versions for free users, and a maximum of 100 stored alert rule versions for paid tier users. +You can view, compare, and restore previous alert rule versions. + +{{< admonition type="note" >}} +In Grafana OSS and Enterprise, the number of alert rule versions is limited. Free users are allowed a maximum of 10 alert rule versions, while paid users have a maximum of 100 stored alert rule versions. +{{< /admonition >}} To view or restore previous versions for an alert rule, complete the following steps. diff --git a/docs/sources/panels-visualizations/visualizations/annotations/index.md b/docs/sources/panels-visualizations/visualizations/annotations/index.md index 673636227ae..d69ea3d95de 100644 --- a/docs/sources/panels-visualizations/visualizations/annotations/index.md +++ b/docs/sources/panels-visualizations/visualizations/annotations/index.md @@ -22,69 +22,71 @@ weight: 100 The annotations list shows a list of available annotations you can use to view annotated data. Various options are available to filter the list based on tags and on the current dashboard. -## Panel options +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-annotations-list-viz-v12.0.png" max-width="750px" alt="The annotations list visualization" >}} -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - -## Annotation query - -The following options control the source query for the list of annotations. - -### Query Filter +## Configuration options -Use the query filter to create a list of annotations from all dashboards in your organization or the current dashboard in which this panel is located. It has the following options: +{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} -- All dashboards - List annotations from all dashboards in the current organization. -- This dashboard - Limit the list to the annotations on the current dashboard. +### Panel options -### Time Range - -Use the time range option to specify whether the list should be limited to the current time range. It has the following options: - -- None - no time range limit for the annotations query. -- This dashboard - Limit the list to the time range of the dashboard where the annotations list is available. +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} -### Tags +### Annotation query options -Use the tags option to filter the annotations by tags. You can add multiple tags in order to refine the list. +The following options control the source query for the list of annotations: -{{% admonition type="note" %}} -Optionally, leave the tag list empty and filter on the fly by selecting tags that are listed as part of the results on the panel itself. -{{% /admonition %}} + -### Limit +| Option | Description | +| ---------- | --------------------------------------------------------------------------------------------------------- | +| [Query filter](#query-filter) | Specify which annotations are included in the list. | +| [Time Range](#time-range) | Specify whether the list should be limited to the current time range. | +| Tags | Filter the annotations by tags. You can add multiple tags to refine the list. Optionally, leave the tag list empty and filter in view mode by selecting tags that are listed as part of the results on the panel itself. | +| Limit | Limit the number of results returned. | -Use the limit option to limit the number of results returned. + -## Display +#### Query filter -These options control additional meta-data included in the annotations list display. +Use the **Query filter** option to create a list of annotations from all dashboards in your organization or the current dashboard in which this panel is located. +Choose from: -### Show user +- **All dashboards** - List annotations from all dashboards in the current organization. +- **This dashboard** - Limit the list to the annotations on the current dashboard. -Use this option to show or hide which user created the annotation. +#### Time Range -### Show time +Specify whether the list should be limited to the current time range. +Choose from: -Use this option to show or hide the time the annotation creation time. +- **None** - No time range limit for the annotations query. +- **This dashboard** - Limit the list to the time range of the dashboard where the annotations list is available. -### Show Tags +### Display options -Use this option to show or hide the tags associated with an annotation. _NB_: You can use the tags to live-filter the annotations list on the visualization itself. +These options control additional metadata included in the annotations list display: -## Link behavior + -### Link target +| Option | Description | +| ---------- | --------------------------------------------------------------------------------------------------------- | +| Show user | Show or hide which user created the annotation. | +| Show time | Show or hide the time the annotation creation time. | +| Show tags | Show or hide the tags associated with an annotation. Note that you can use the tags to filter the annotations list. | -Use this option to chose how to view the annotated data. It has the following options. + -- Panel - This option will take you directly to a full-screen view of the panel with the corresponding annotation -- Dashboard - This option will focus the annotation in the context of a complete dashboard +### Link behavior options -### Time before +Use the following options to control the behavior of annotation links in the list: -Use this option to set the time range before the annotation. Use duration string values like "1h" = 1 hour, "10m" = 10 minutes, etc. + -### Time after +| Option | Description | +| ---------- | --------------------------------------------------------------------------------------------------------- | +| Link target | Set how to view the annotated data. Choose from:
  • **Panel** - The link takes you directly to a full-screen view of the panel with the corresponding annotation.
  • **Dashboard** - Focuses the annotation in the context of a complete dashboard.
| +| Time before | Set the time range before the annotation. Use duration string values like `1h` for one hour and `10m` for 10 minutes. | +| Time after | Set the time range after the annotation. | -Use this option to set the time range after the annotation. + diff --git a/docs/sources/panels-visualizations/visualizations/datagrid/index.md b/docs/sources/panels-visualizations/visualizations/datagrid/index.md index 4d32a93d20f..60d9aab5201 100644 --- a/docs/sources/panels-visualizations/visualizations/datagrid/index.md +++ b/docs/sources/panels-visualizations/visualizations/datagrid/index.md @@ -29,23 +29,20 @@ refs: # Datagrid -{{% admonition type="note" %}} - -The Grafana datagrid is experimental. This feature is supported by the engineering team on a best-effort basis, and breaking changes may occur without notice prior to general availability. - -{{% /admonition %}} +{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}} Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels inside a dashboard. -![Datagrid panel](/media/docs/datagrid/screenshot-grafana-datagrid-panel.png) +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-datagrid-visualization-v12.0.png" max-width="750px" alt="The datagrid visualization" >}} -Through it, you can manipulate data queried from any data source, you can start from a blank slate, or you can pull data from a dragged and dropped file. You can then use the panel as a simple tabular -visualization, or you can modify the data—and even remove it altogether—to create a blank slate. +Through it, you can manipulate data queried from any data source, you can start from a blank slate, or you can pull data from a dragged and dropped file. +You can then use the panel as a simple tabular visualization, or you can modify the data—and even remove it altogether—to create a blank slate. Editing the dataset changes the data source to use the inbuilt `-- Grafana --` data source, thus replacing the old data source settings and related queries, while also copying the current dataset into the dashboard model. -You can then use the panel as a data source for other panels, by using the inbuilt `-- Dashboard --` data source to pull the datagrid data. This allows for an interactive dashboard experience, where you can modify the data and see the changes reflected in other panels. +You can then use the panel as a data source for other panels, by using the inbuilt `-- Dashboard --` data source to pull the datagrid data. +This allows for an interactive dashboard experience, where you can modify the data and see the changes reflected in other panels. Learn more about the inbuilt `-- Grafana --` and `-- Dashboard --` data sources in the [special data sources](ref:special-data-sources) documentation. @@ -65,13 +62,9 @@ Deleting a row or column will remove the data from the datagrid, while clearing You can also access a header menu by clicking the dropdown icon next to the header title. From here, you can not only delete or clear a column, but also rename it, freeze it, or convert the field type of the column. -{{< figure src="/media/docs/datagrid/screenshot-grafana-datagrid-header-menu-2.png" alt="Datagrid header menu" max-width="500px" >}} - -## Selecting series - -If there are multiple series, you can set the datagrid to display the preferred dataset using the **Select series** dropdown in the panel options. +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-datagrid-header-menu-v12.0.png" alt="Datagrid header menu" max-width="400px" >}} -## Using datagrids +## Use datagrids Datagrids offer various ways of interacting with your data. You can add, edit, move, clear, and remove rows and columns; use the inbuilt search functionality to find specific data; and convert field types or freeze horizontal scroll on a specific column. @@ -79,7 +72,7 @@ Datagrids offer various ways of interacting with your data. You can add, edit, m You can add data to a datagrid by creating a new column or row. -To create a new column, take the following steps: +To create a new column, follow these steps: 1. In an existing panel, click the **+** button in the table header after the last column. 1. When prompted, add a name for the new column. @@ -91,7 +84,7 @@ To add a new row, click a **+** button after the last row. The button is present ### Edit data -You can edit data by taking the following steps: +To edit data, follow these steps: 1. Double-click on the cell that needs to be modified. This will activate the cell and allow you to edit the data. 1. After editing the data, click anywhere outside the cell or press the Enter key to finalize the edit. @@ -102,7 +95,7 @@ To easily clear a cell of data, you can click on a cell to focus it and then pre You can move columns and rows as needed. -To move a column, take the following steps: +To move a column, follow these steps: 1. Click and hold the header of the column that needs to be moved. 1. Drag the column to the desired location. @@ -116,7 +109,7 @@ You can select multiple cells by clicking on a single cell and dragging the mous ### Delete/clear multiple rows or columns -To delete or clear multiple rows, take the following steps: +To delete or clear multiple rows, follow these steps: 1. Hover over the number column (to the left of the first column in the grid) to display row checkbox. 1. Select the checkboxes for the rows you want to work with. @@ -126,8 +119,16 @@ To delete or clear multiple rows, take the following steps: The same rules apply to columns by clicking the column headers. -To delete all rows, use the "select all" checkbox at the top left corner of the datagrid. This selects all rows and allows you to delete them using the context menu. +To delete all rows, use the select all checkbox at the top left corner of the datagrid. This selects all rows and allows you to delete them using the context menu. -## Panel options +## Configuration options + +{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} + +### Panel options {{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} + +### Datagrid options + +If there are multiple series, you can choose the dataset the datagrid displays using the **Select series** option. diff --git a/docs/sources/panels-visualizations/visualizations/news/index.md b/docs/sources/panels-visualizations/visualizations/news/index.md index 8cb516606e6..5ceaa44605c 100644 --- a/docs/sources/panels-visualizations/visualizations/news/index.md +++ b/docs/sources/panels-visualizations/visualizations/news/index.md @@ -34,7 +34,7 @@ You can use the news visualization to provide regular news and updates to your u ## Configure a news visualization -Once you’ve created a [dashboard](https://grafana.com/docs/grafana//dashboards/build-dashboards/create-dashboard/), enter the URL of an RSS in the [URL](#url) field in the **News** section. This visualization type doesn't accept any other queries, and you shouldn't expect to be able to filter or query the RSS feed data in any way using this visualization. +After you’ve created a [dashboard](https://grafana.com/docs/grafana//dashboards/build-dashboards/create-dashboard/), enter the URL of an RSS in the **URL** field in the **News** section. This visualization type doesn't accept any other queries, and you shouldn't expect to be able to filter or query the RSS feed data in any way using this visualization. If you're having trouble loading an RSS feed, you can try rehosting the feed on a different server or using a CORS proxy. A CORS proxy is a tool that allows you to bypass CORS restrictions by making requests to the RSS feed on your behalf. You can find more information about using CORS proxies online. @@ -44,18 +44,17 @@ If you're unable to display an RSS feed using the news visualization, you can tr The news visualization supports RSS and Atom feeds. -## Panel options +## Configuration options -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - -## News options +{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} -Use the following options to refine your news visualization. +### Panel options -### URL +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} -The URL of the RSS or Atom feed. +### News options -### Show image +Use the following options to refine your news visualization: -Controls if the news social image is displayed beside the text content. +- **URL** - The URL of the RSS or Atom feed. +- **Show image** - Controls if the news social image is displayed beside the text content. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 51df92d1ac0..b8262500040 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -47,6 +47,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | | `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | Yes | +| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes | | `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes | | `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes | | `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | @@ -156,7 +157,6 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | -| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | | `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md index 760079cf103..fa4fdc74b9f 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md @@ -44,20 +44,29 @@ SCIM offers several advantages for managing users and teams in Grafana: - **Reduced administrative overhead**: Eliminate manual user management tasks and reduce the risk of human error - **Enhanced security**: Automatically disable access when users leave your organization -## Identity provider consistency +## Authentication and access requirements -Grafana follows the best practice of not mixing different identity providers and SSO methods. When you enable SCIM in Grafana, you must use the same identity provider for both authentication and user provisioning. This means that users attempting to log in through other authentication methods like LDAP or OAuth will be blocked from accessing Grafana. +When you enable SCIM in Grafana, the following requirements and restrictions apply: -Users with Basic Auth credentials and those using their grafana.com accounts will still be able to log in successfully. +1. **Use the same identity provider**: You must use the same identity provider for both authentication and user provisioning. For example, if you use Azure AD for SCIM, you must also use Azure AD for authentication. + +2. **Authentication restrictions**: + + - Users attempting to log in through other methods (LDAP, OAuth) will be blocked + - By default, users who are not provisioned through SCIM cannot access Grafana + - You can allow non-SCIM users by setting `allow_non_provisioned_users = true` + +3. **Exceptions**: Users with Basic Auth credentials and those using their Grafana Cloud accounts can still log in regardless of these restrictions. ## Configure SCIM in Grafana The table below describes all SCIM configuration options. Like any other Grafana configuration, you can apply these options as [environment variables](/docs/grafana//setup-grafana/configure-grafana/#override-configuration-with-environment-variables). -| Setting | Required | Description | Default | -| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | -| `user_sync_enabled` | Yes | Enable SCIM user provisioning. When enabled, Grafana will create, update, and deactivate users based on SCIM requests from your identity provider. | `true` | -| `group_sync_enabled` | No | Enable SCIM group provisioning. When enabled, Grafana will create, update, and delete teams based on SCIM requests from your identity provider. Cannot be enabled if Team Sync is enabled. | `false` | +| Setting | Required | Description | Default | +| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `user_sync_enabled` | Yes | Enable SCIM user provisioning. When enabled, Grafana will create, update, and deactivate users based on SCIM requests from your identity provider. | `false` | +| `group_sync_enabled` | No | Enable SCIM group provisioning. When enabled, Grafana will create, update, and delete teams based on SCIM requests from your identity provider. Cannot be enabled if Team Sync is enabled. | `false` | +| `allow_non_provisioned_users` | No | Allow non SCIM provisioned users to sign in to Grafana. | `false` | {{< admonition type="warning" >}} **Team Sync Compatibility**: diff --git a/e2e/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e/dashboards-suite/dashboard-links-without-slug.spec.ts new file mode 100644 index 00000000000..defc7678d3b --- /dev/null +++ b/e2e/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -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'); + } +}; diff --git a/e2e/dashboards/DataLinkWithoutSlugTest.json b/e2e/dashboards/DataLinkWithoutSlugTest.json new file mode 100644 index 00000000000..afcd6c0c4e3 --- /dev/null +++ b/e2e/dashboards/DataLinkWithoutSlugTest.json @@ -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 +} diff --git a/go.mod b/go.mod index 1ee5e09a7ea..1f1bbd67d1c 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/googleapis/go-sql-spanner v1.11.1 // @grafana/grafana-search-and-storage github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -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 diff --git a/go.sum b/go.sum index 808c51502de..be1f64f196d 100644 --- a/go.sum +++ b/go.sum @@ -1565,8 +1565,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692 h1:5mOChD6fbkxeafK1iuy/iO/qKLyHeougMTtRJSgvAUM= -github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692/go.mod h1:3ER/8BhIEhvrddcztLQSc5ez1f1jNHIPdquc1F+DzOw= +github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430 h1:qT0D7AIV0GRu8JUrSJYuyzj86kqLgksKQjwD++DqyOM= +github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430/go.mod h1:3ER/8BhIEhvrddcztLQSc5ez1f1jNHIPdquc1F+DzOw= github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d h1:TDVZemfYeJHPyXeYCnqL7BQqsa+mpaZYth/Qm3TKaT8= github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us= github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM= diff --git a/go.work.sum b/go.work.sum index 22028db7e3a..c5f245e6b72 100644 --- a/go.work.sum +++ b/go.work.sum @@ -575,6 +575,7 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 2426ed3338c..3e6de71e8d3 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -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 { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 154c070f860..89c02a1a693 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -336,6 +336,7 @@ export interface FeatureToggles { kubernetesDashboards?: boolean; /** * Route the folder and dashboard service requests to k8s + * @default true */ kubernetesClientDashboardsFolders?: boolean; /** diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 6c53ddcb48a..d3dea16ff5c 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -269,6 +269,7 @@ export const availableIconsIndex = { 'add-user': true, attach: true, 'dollar-alt': true, + 'ai-sparkle': true, }; export type IconName = keyof typeof availableIconsIndex; diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx index 5e752bd89f8..0de1f240694 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx @@ -249,7 +249,7 @@ export function MetricSelect({ return ( | undefined; listDashboardScopesEndpoint?: string | undefined; diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index e7119717bf3..de669f7af74 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -25,3 +25,5 @@ export { setGetObservablePluginLinks, type GetObservablePluginLinks, } from '../services/pluginExtensions/getObservablePluginLinks'; + +export { UserStorage } from '../utils/userStorage'; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts index c256f77baea..e249d727a68 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts @@ -37,6 +37,14 @@ class MyDataSource extends DataSourceWithBackend { 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, 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() { diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 3d5a32dc252..ab3a2b7670d 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -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 { + protected userStorage: UserStorage; + constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); + this.userStorage = new UserStorage(instanceSettings.type); } /** diff --git a/packages/grafana-runtime/src/utils/userStorage.test.tsx b/packages/grafana-runtime/src/utils/userStorage.test.tsx index 46999241686..91fe2374508 100644 --- a/packages/grafana-runtime/src/utils/userStorage.test.tsx +++ b/packages/grafana-runtime/src/utils/userStorage.test.tsx @@ -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 () => { diff --git a/packages/grafana-runtime/src/utils/userStorage.tsx b/packages/grafana-runtime/src/utils/userStorage.tsx index 57ed8354404..245322e0a68 100644 --- a/packages/grafana-runtime/src/utils/userStorage.tsx +++ b/packages/grafana-runtime/src/utils/userStorage.tsx @@ -37,9 +37,9 @@ async function apiRequest(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 { 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 { if (!this.canUseUserStorage) { // Fallback to localStorage - localStorage.setItem(key, value); + localStorage.setItem(`${this.resourceName}:${key}`, value); return; } diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 41fd55839ac..4e15cffeb3d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -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 -} \ No newline at end of file +} diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index a53da1e3aad..3106f1b6f8f 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -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(), }); - diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index fb3b3b17e33..1a4fad82b96 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -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: [], }); - diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index ad160d6fb21..d35b8157b7a 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -65,10 +65,12 @@ export function useOptions(rawOptions: AsyncOptions>) => { let currentOptions: Array> = opts; if (createCustomValue && userTypedSearch) { - //Since the label of a normal option does not have to match its value and a custom option has the same value and label, - //we just focus on the value to check if the option already exists + // Since the label of a normal option does not have to match its value and a custom option has the same value and label, + // we just focus on the value to check if the option already exists const customValueExists = opts.some((opt) => opt.value === userTypedSearch); if (!customValueExists) { + // Make sure to clone the array first to avoid mutating the original array! + currentOptions = currentOptions.slice(); currentOptions.unshift({ label: userTypedSearch, value: userTypedSearch as T, diff --git a/packages/grafana-ui/src/components/Forms/Checkbox.tsx b/packages/grafana-ui/src/components/Forms/Checkbox.tsx index 3e5e2693f80..fa3f9222b71 100644 --- a/packages/grafana-ui/src/components/Forms/Checkbox.tsx +++ b/packages/grafana-ui/src/components/Forms/Checkbox.tsx @@ -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, }) ), }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx index 3e3138e55ef..1a1c3bbca5a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx @@ -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'); } }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 446c5023645..01934d0da2e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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', }, }, diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index b9c66d8de32..16ee4d2ac3d 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -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"` diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 96e8b75a0ec..a8405044fcf 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -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, }, } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index c069cf8cf9a..935455a09c4 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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, diff --git a/pkg/api/index.go b/pkg/api/index.go index 9697fc3a02f..016a78366c6 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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 diff --git a/pkg/apis/folder/v0alpha1/doc.go b/pkg/apis/folder/v1/doc.go similarity index 59% rename from pkg/apis/folder/v0alpha1/doc.go rename to pkg/apis/folder/v1/doc.go index fc85f689f0e..7a4e5ab7643 100644 --- a/pkg/apis/folder/v0alpha1/doc.go +++ b/pkg/apis/folder/v1/doc.go @@ -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" diff --git a/pkg/apis/folder/v0alpha1/register.go b/pkg/apis/folder/v1/register.go similarity index 96% rename from pkg/apis/folder/v0alpha1/register.go rename to pkg/apis/folder/v1/register.go index 27f1e0ed670..ebfb2b6e122 100644 --- a/pkg/apis/folder/v0alpha1/register.go +++ b/pkg/apis/folder/v1/register.go @@ -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 diff --git a/pkg/apis/folder/v0alpha1/types.go b/pkg/apis/folder/v1/types.go similarity index 99% rename from pkg/apis/folder/v0alpha1/types.go rename to pkg/apis/folder/v1/types.go index ceaaaf1233b..4a301680b80 100644 --- a/pkg/apis/folder/v0alpha1/types.go +++ b/pkg/apis/folder/v1/types.go @@ -1,4 +1,4 @@ -package v0alpha1 +package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/folder/v1/zz_generated.deepcopy.go similarity index 99% rename from pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go rename to pkg/apis/folder/v1/zz_generated.deepcopy.go index 7a70c772be0..4e65160d2d3 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/folder/v1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ // Code generated by deepcopy-gen. DO NOT EDIT. -package v0alpha1 +package v1 import ( runtime "k8s.io/apimachinery/pkg/runtime" diff --git a/pkg/apis/folder/v0alpha1/zz_generated.defaults.go b/pkg/apis/folder/v1/zz_generated.defaults.go similarity index 96% rename from pkg/apis/folder/v0alpha1/zz_generated.defaults.go rename to pkg/apis/folder/v1/zz_generated.defaults.go index 238fc2f4edc..e889bc10780 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.defaults.go +++ b/pkg/apis/folder/v1/zz_generated.defaults.go @@ -5,7 +5,7 @@ // Code generated by defaulter-gen. DO NOT EDIT. -package v0alpha1 +package v1 import ( runtime "k8s.io/apimachinery/pkg/runtime" diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go b/pkg/apis/folder/v1/zz_generated.openapi.go similarity index 83% rename from pkg/apis/folder/v0alpha1/zz_generated.openapi.go rename to pkg/apis/folder/v1/zz_generated.openapi.go index c3c9f74bfc4..244b064854d 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/folder/v1/zz_generated.openapi.go @@ -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{ diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/folder/v1/zz_generated.openapi_violation_exceptions.list similarity index 56% rename from pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list rename to pkg/apis/folder/v1/zz_generated.openapi_violation_exceptions.list index f738ebaa1eb..17876d3b1c3 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apis/folder/v1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 6996422fba6..9c069f96ad4 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -394,11 +394,14 @@ type TestResults struct { // Is the connection healthy Success bool `json:"success"` - // Error descriptions - Errors []string `json:"errors,omitempty"` + // Field related errors + Errors []ErrorDetails `json:"errors,omitempty"` +} - // Optional details - Details *common.Unstructured `json:"details,omitempty"` +type ErrorDetails struct { + Type metav1.CauseType `json:"type"` + Field string `json:"field,omitempty"` + Detail string `json:"detail,omitempty"` } // HistoryList is a list of versions of a resource diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 8e09d703d09..2beb7403f6a 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ErrorDetails) DeepCopyInto(out *ErrorDetails) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorDetails. +func (in *ErrorDetails) DeepCopy() *ErrorDetails { + if in == nil { + return nil + } + out := new(ErrorDetails) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) { *out = *in @@ -849,13 +865,9 @@ func (in *TestResults) DeepCopyInto(out *TestResults) { out.TypeMeta = in.TypeMeta if in.Errors != nil { in, out := &in.Errors, &out.Errors - *out = make([]string, len(*in)) + *out = make([]ErrorDetails, len(*in)) copy(*out, *in) } - if in.Details != nil { - in, out := &in.Details, &out.Details - *out = (*in).DeepCopy() - } return } diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 184ea75003a..4f9483d09b7 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -15,6 +15,7 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref), + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), @@ -88,6 +89,38 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback) } } +func schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "field": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "detail": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"type"}, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1909,31 +1942,24 @@ func schema_pkg_apis_provisioning_v0alpha1_TestResults(ref common.ReferenceCallb }, "errors": { SchemaProps: spec.SchemaProps{ - Description: "Error descriptions", + Description: "Field related errors", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"), }, }, }, }, }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "Optional details", - Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), - }, - }, }, Required: []string{"code", "success"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"}, } } diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 913e6173b68..0d0abdf95bf 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -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" diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index 92ca25ae144..58876d72f96 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -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", diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index 26da721dada..67b7336900c 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index fbb6d8f6b09..a3accabadad 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -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) diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index a347774097b..55d37301d7c 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -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") diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index c37b495ba99..6e702edde48 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -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) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index f78b0b50b82..82a5ed934f1 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -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 } diff --git a/pkg/registry/apis/dashboard/register_test.go b/pkg/registry/apis/dashboard/register_test.go index 2bea09b35f9..b149515e0ad 100644 --- a/pkg/registry/apis/dashboard/register_test.go +++ b/pkg/registry/apis/dashboard/register_test.go @@ -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(), }, }, } diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 86967a9f5d3..4e286491ce1 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -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 } diff --git a/pkg/registry/apis/folders/authorizer_test.go b/pkg/registry/apis/folders/authorizer_test.go index eced4071112..100afc9da85 100644 --- a/pkg/registry/apis/folders/authorizer_test.go +++ b/pkg/registry/apis/folders/authorizer_test.go @@ -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 { diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 3159e9acf68..174683b3b60 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -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, }, diff --git a/pkg/registry/apis/folders/folder_storage.go b/pkg/registry/apis/folders/folder_storage.go index 4bc271c67b0..9b1f1c43da5 100644 --- a/pkg/registry/apis/folders/folder_storage.go +++ b/pkg/registry/apis/folders/folder_storage.go @@ -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?") } diff --git a/pkg/registry/apis/folders/folder_storage_test.go b/pkg/registry/apis/folders/folder_storage_test.go index 31cc755b6f3..68c2a808902 100644 --- a/pkg/registry/apis/folders/folder_storage_test.go +++ b/pkg/registry/apis/folders/folder_storage_test.go @@ -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{ diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index 8c350cacc67..155e2d2d5a7 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -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") } diff --git a/pkg/registry/apis/folders/legacy_storage_test.go b/pkg/registry/apis/folders/legacy_storage_test.go index ad877424e36..ee77264ca9c 100644 --- a/pkg/registry/apis/folders/legacy_storage_test.go +++ b/pkg/registry/apis/folders/legacy_storage_test.go @@ -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) diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 9692dfcbd25..fd1e717b03d 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -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) { diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 17ba1f542e1..5c8f90e7e1a 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -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, diff --git a/pkg/registry/apis/folders/sub_access.go b/pkg/registry/apis/folders/sub_access.go index b0b2cf7f546..ccdc5afa039 100644 --- a/pkg/registry/apis/folders/sub_access.go +++ b/pkg/registry/apis/folders/sub_access.go @@ -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() diff --git a/pkg/registry/apis/folders/sub_count.go b/pkg/registry/apis/folders/sub_count.go index 2316527c004..062a6fde7b2 100644 --- a/pkg/registry/apis/folders/sub_count.go +++ b/pkg/registry/apis/folders/sub_count.go @@ -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, diff --git a/pkg/registry/apis/folders/sub_parent_test.go b/pkg/registry/apis/folders/sub_parent_test.go index 0c29800dbf4..c383d5b7245 100644 --- a/pkg/registry/apis/folders/sub_parent_test.go +++ b/pkg/registry/apis/folders/sub_parent_test.go @@ -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"}}, }}, diff --git a/pkg/registry/apis/folders/sub_parents.go b/pkg/registry/apis/folders/sub_parents.go index 87f9a999c1f..598b5ae4d0e 100644 --- a/pkg/registry/apis/folders/sub_parents.go +++ b/pkg/registry/apis/folders/sub_parents.go @@ -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), diff --git a/pkg/registry/apis/provisioning/controller/finalizers.go b/pkg/registry/apis/provisioning/controller/finalizers.go index 137bd9e7055..1420f9ab958 100644 --- a/pkg/registry/apis/provisioning/controller/finalizers.go +++ b/pkg/registry/apis/provisioning/controller/finalizers.go @@ -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" diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 3274ed0928b..f4c7e09d7e9 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -271,18 +271,22 @@ func (rc *RepositoryController) runHealthCheck(ctx context.Context, repo reposit if err != nil { res = &provisioning.TestResults{ Success: false, - Errors: []string{ - "error running test repository", - err.Error(), - }, + Errors: []provisioning.ErrorDetails{{ + Detail: fmt.Sprintf("error running test repository: %s", err.Error()), + }}, } } healthStatus := provisioning.HealthStatus{ Healthy: res.Success, Checked: time.Now().UnixMilli(), - Message: res.Errors, } + for _, err := range res.Errors { + if err.Detail != "" { + healthStatus.Message = append(healthStatus.Message, err.Detail) + } + } + logger.Info("health check completed", "status", healthStatus) return healthStatus diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker.go b/pkg/registry/apis/provisioning/jobs/migrate/worker.go index b2992e6d261..657b4348731 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker.go @@ -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 { diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/changes.go b/pkg/registry/apis/provisioning/jobs/pullrequest/changes.go new file mode 100644 index 00000000000..961485b1eb6 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/changes.go @@ -0,0 +1,233 @@ +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 evaluator struct { + render ScreenshotRenderer + parsers resources.ParserFactory + urlProvider func(namespace string) string +} + +func NewEvaluator(render ScreenshotRenderer, parsers resources.ParserFactory, urlProvider func(namespace string) string) Evaluator { + return &evaluator{ + render: render, + parsers: parsers, + urlProvider: urlProvider, + } +} + +// This will process the list of versioned file changes into changeInfo +func (e *evaluator) Evaluate(ctx context.Context, repo repository.Reader, opts provisioning.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error) { + cfg := repo.Config() + parser, err := e.parsers.GetParser(ctx, repo) + if err != nil { + return changeInfo{}, fmt.Errorf("failed to get parser for %s: %w", cfg.Name, err) + } + + baseURL := e.urlProvider(cfg.Namespace) + info := changeInfo{ + GrafanaBaseURL: baseURL, + } + + var shouldRender bool + switch { + case e.render == nil: + shouldRender = false + case !e.render.IsAvailable(ctx): + info.MissingImageRenderer = true + shouldRender = false + case len(changes) > 1 || !cfg.Spec.GitHub.GenerateDashboardPreviews: + // Only render images when there is just one change + shouldRender = false + default: + shouldRender = true + } + + logger := logging.FromContext(ctx) + for i, change := range changes { + // process maximum 10 files + if i >= 10 { + info.SkippedFiles = len(changes) - i + break + } + + progress.SetMessage(ctx, fmt.Sprintf("processing: %s", change.Path)) + logger.With("action", change.Action).With("path", change.Path) + + v, err := calculateFileChangeInfo(ctx, repo, info.GrafanaBaseURL, change, opts, parser) + if err != nil { + return info, fmt.Errorf("error calculating changes %w", err) + } + + // If everything applied OK, then render screenshots + if shouldRender && v.GrafanaURL != "" && v.Parsed != nil && v.Parsed.DryRunResponse != nil { + progress.SetMessage(ctx, fmt.Sprintf("rendering screenshots: %s", change.Path)) + if err = v.renderScreenshots(ctx, info.GrafanaBaseURL, e.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, repo repository.Reader, baseURL string, change repository.VersionedFileChange, opts provisioning.PullRequestJobOptions, parser resources.Parser) (fileChangeInfo, error) { + if change.Action == repository.FileActionDeleted { + return calculateFileDeleteInfo(ctx, baseURL, change) + } + + info := fileChangeInfo{Change: change} + fileInfo, err := repo.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 = 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.URL != "" { + query.Set("pull_request_url", url.QueryEscape(opts.URL)) + } + info.PreviewURL += "?" + query.Encode() + } + + return info, nil +} + +func calculateFileDeleteInfo(_ context.Context, _ string, change repository.VersionedFileChange) (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 +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/changes_test.go b/pkg/registry/apis/provisioning/jobs/pullrequest/changes_test.go new file mode 100644 index 00000000000..c45374cc1f4 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/changes_test.go @@ -0,0 +1,208 @@ +package pullrequest + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "testing" + + "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" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +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) + reader.On("Config").Return(&v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "x", + }, + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + GenerateDashboardPreviews: true, + }, + }, + }) + 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) + changes := []repository.VersionedFileChange{createdFileChange} + + parserFactory := resources.NewMockParserFactory(t) + parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil) + evaluator := NewEvaluator(renderer, parserFactory, func(_ string) string { + return "http://host/" + }) + + info, err := evaluator.Evaluate(context.Background(), reader, pullRequest, changes, progress) + 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) + changes := []repository.VersionedFileChange{createdFileChange} + parserFactory := resources.NewMockParserFactory(t) + parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil) + evaluator := NewEvaluator(renderer, parserFactory, func(_ string) string { + return "http://host/" + }) + + info, err := evaluator.Evaluate(context.Background(), reader, pullRequest, changes, progress) + 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) + + changes := []repository.VersionedFileChange{} + for range 15 { + changes = append(changes, createdFileChange) + } + + parserFactory := resources.NewMockParserFactory(t) + parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil) + evaluator := NewEvaluator(renderer, parserFactory, func(_ string) string { + return "http://host/" + }) + + info, err := evaluator.Evaluate(context.Background(), reader, pullRequest, changes, progress) + 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] +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/comment.go b/pkg/registry/apis/provisioning/jobs/pullrequest/comment.go new file mode 100644 index 00000000000..12003b73305 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/comment.go @@ -0,0 +1,124 @@ +package pullrequest + +import ( + "bytes" + "context" + "fmt" + "html/template" + "path/filepath" + "strings" +) + +type commenter struct { + templateDashboard *template.Template + templateTable *template.Template + templateRenderInfo *template.Template +} + +func NewCommenter() Commenter { + return &commenter{ + 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 *commenter) 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 *commenter) 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 +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/comment_test.go b/pkg/registry/apis/provisioning/jobs/pullrequest/comment_test.go new file mode 100644 index 00000000000..ec0d0da27bf --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/comment_test.go @@ -0,0 +1,134 @@ +package pullrequest + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "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) { + 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) { + repo := NewMockPullRequestRepo(t) + + // expectation on the comment + fpath := filepath.Join("testdata", strings.ReplaceAll(tc.Name, " ", "-")+".md") + // We can ignore the gosec G304 because this is only for tests + // nolint:gosec + expect, err := os.ReadFile(fpath) + require.NoError(t, err) + repo.On("CommentPullRequest", context.Background(), 1, string(expect)).Return(nil) + + commenter := NewCommenter() + err = commenter.Comment(context.Background(), repo, 1, tc.Input) + require.NoError(t, err) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/mock_commenter.go b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_commenter.go new file mode 100644 index 00000000000..785e552f68d --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_commenter.go @@ -0,0 +1,85 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package pullrequest + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockCommenter is an autogenerated mock type for the Commenter type +type MockCommenter struct { + mock.Mock +} + +type MockCommenter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockCommenter) EXPECT() *MockCommenter_Expecter { + return &MockCommenter_Expecter{mock: &_m.Mock} +} + +// Comment provides a mock function with given fields: ctx, repo, pr, changeInfo3 +func (_m *MockCommenter) Comment(ctx context.Context, repo PullRequestRepo, pr int, changeInfo3 changeInfo) error { + ret := _m.Called(ctx, repo, pr, changeInfo3) + + if len(ret) == 0 { + panic("no return value specified for Comment") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, PullRequestRepo, int, changeInfo) error); ok { + r0 = rf(ctx, repo, pr, changeInfo3) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCommenter_Comment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comment' +type MockCommenter_Comment_Call struct { + *mock.Call +} + +// Comment is a helper method to define mock.On call +// - ctx context.Context +// - repo PullRequestRepo +// - pr int +// - changeInfo3 changeInfo +func (_e *MockCommenter_Expecter) Comment(ctx interface{}, repo interface{}, pr interface{}, changeInfo3 interface{}) *MockCommenter_Comment_Call { + return &MockCommenter_Comment_Call{Call: _e.mock.On("Comment", ctx, repo, pr, changeInfo3)} +} + +func (_c *MockCommenter_Comment_Call) Run(run func(ctx context.Context, repo PullRequestRepo, pr int, changeInfo3 changeInfo)) *MockCommenter_Comment_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(PullRequestRepo), args[2].(int), args[3].(changeInfo)) + }) + return _c +} + +func (_c *MockCommenter_Comment_Call) Return(_a0 error) *MockCommenter_Comment_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCommenter_Comment_Call) RunAndReturn(run func(context.Context, PullRequestRepo, int, changeInfo) error) *MockCommenter_Comment_Call { + _c.Call.Return(run) + return _c +} + +// NewMockCommenter creates a new instance of MockCommenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockCommenter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCommenter { + mock := &MockCommenter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/mock_evaluator.go b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_evaluator.go new file mode 100644 index 00000000000..54673cd92d8 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_evaluator.go @@ -0,0 +1,101 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package pullrequest + +import ( + context "context" + + jobs "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + mock "github.com/stretchr/testify/mock" + + repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// MockEvaluator is an autogenerated mock type for the Evaluator type +type MockEvaluator struct { + mock.Mock +} + +type MockEvaluator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockEvaluator) EXPECT() *MockEvaluator_Expecter { + return &MockEvaluator_Expecter{mock: &_m.Mock} +} + +// Evaluate provides a mock function with given fields: ctx, repo, opts, changes, progress +func (_m *MockEvaluator) Evaluate(ctx context.Context, repo repository.Reader, opts v0alpha1.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error) { + ret := _m.Called(ctx, repo, opts, changes, progress) + + if len(ret) == 0 { + panic("no return value specified for Evaluate") + } + + var r0 changeInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) (changeInfo, error)); ok { + return rf(ctx, repo, opts, changes, progress) + } + if rf, ok := ret.Get(0).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) changeInfo); ok { + r0 = rf(ctx, repo, opts, changes, progress) + } else { + r0 = ret.Get(0).(changeInfo) + } + + if rf, ok := ret.Get(1).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) error); ok { + r1 = rf(ctx, repo, opts, changes, progress) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockEvaluator_Evaluate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Evaluate' +type MockEvaluator_Evaluate_Call struct { + *mock.Call +} + +// Evaluate is a helper method to define mock.On call +// - ctx context.Context +// - repo repository.Reader +// - opts v0alpha1.PullRequestJobOptions +// - changes []repository.VersionedFileChange +// - progress jobs.JobProgressRecorder +func (_e *MockEvaluator_Expecter) Evaluate(ctx interface{}, repo interface{}, opts interface{}, changes interface{}, progress interface{}) *MockEvaluator_Evaluate_Call { + return &MockEvaluator_Evaluate_Call{Call: _e.mock.On("Evaluate", ctx, repo, opts, changes, progress)} +} + +func (_c *MockEvaluator_Evaluate_Call) Run(run func(ctx context.Context, repo repository.Reader, opts v0alpha1.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder)) *MockEvaluator_Evaluate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(repository.Reader), args[2].(v0alpha1.PullRequestJobOptions), args[3].([]repository.VersionedFileChange), args[4].(jobs.JobProgressRecorder)) + }) + return _c +} + +func (_c *MockEvaluator_Evaluate_Call) Return(_a0 changeInfo, _a1 error) *MockEvaluator_Evaluate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockEvaluator_Evaluate_Call) RunAndReturn(run func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) (changeInfo, error)) *MockEvaluator_Evaluate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockEvaluator creates a new instance of MockEvaluator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockEvaluator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockEvaluator { + mock := &MockEvaluator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/mock_pullrequest_repo.go b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_pullrequest_repo.go new file mode 100644 index 00000000000..882ee50cbe9 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/mock_pullrequest_repo.go @@ -0,0 +1,351 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package pullrequest + +import ( + context "context" + + repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + mock "github.com/stretchr/testify/mock" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// MockPullRequestRepo is an autogenerated mock type for the PullRequestRepo type +type MockPullRequestRepo struct { + mock.Mock +} + +type MockPullRequestRepo_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPullRequestRepo) EXPECT() *MockPullRequestRepo_Expecter { + return &MockPullRequestRepo_Expecter{mock: &_m.Mock} +} + +// ClearAllPullRequestFileComments provides a mock function with given fields: ctx, pr +func (_m *MockPullRequestRepo) ClearAllPullRequestFileComments(ctx context.Context, pr int) error { + ret := _m.Called(ctx, pr) + + if len(ret) == 0 { + panic("no return value specified for ClearAllPullRequestFileComments") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, pr) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPullRequestRepo_ClearAllPullRequestFileComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearAllPullRequestFileComments' +type MockPullRequestRepo_ClearAllPullRequestFileComments_Call struct { + *mock.Call +} + +// ClearAllPullRequestFileComments is a helper method to define mock.On call +// - ctx context.Context +// - pr int +func (_e *MockPullRequestRepo_Expecter) ClearAllPullRequestFileComments(ctx interface{}, pr interface{}) *MockPullRequestRepo_ClearAllPullRequestFileComments_Call { + return &MockPullRequestRepo_ClearAllPullRequestFileComments_Call{Call: _e.mock.On("ClearAllPullRequestFileComments", ctx, pr)} +} + +func (_c *MockPullRequestRepo_ClearAllPullRequestFileComments_Call) Run(run func(ctx context.Context, pr int)) *MockPullRequestRepo_ClearAllPullRequestFileComments_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockPullRequestRepo_ClearAllPullRequestFileComments_Call) Return(_a0 error) *MockPullRequestRepo_ClearAllPullRequestFileComments_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPullRequestRepo_ClearAllPullRequestFileComments_Call) RunAndReturn(run func(context.Context, int) error) *MockPullRequestRepo_ClearAllPullRequestFileComments_Call { + _c.Call.Return(run) + return _c +} + +// CommentPullRequest provides a mock function with given fields: ctx, pr, comment +func (_m *MockPullRequestRepo) CommentPullRequest(ctx context.Context, pr int, comment string) error { + ret := _m.Called(ctx, pr, comment) + + if len(ret) == 0 { + panic("no return value specified for CommentPullRequest") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = rf(ctx, pr, comment) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPullRequestRepo_CommentPullRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommentPullRequest' +type MockPullRequestRepo_CommentPullRequest_Call struct { + *mock.Call +} + +// CommentPullRequest is a helper method to define mock.On call +// - ctx context.Context +// - pr int +// - comment string +func (_e *MockPullRequestRepo_Expecter) CommentPullRequest(ctx interface{}, pr interface{}, comment interface{}) *MockPullRequestRepo_CommentPullRequest_Call { + return &MockPullRequestRepo_CommentPullRequest_Call{Call: _e.mock.On("CommentPullRequest", ctx, pr, comment)} +} + +func (_c *MockPullRequestRepo_CommentPullRequest_Call) Run(run func(ctx context.Context, pr int, comment string)) *MockPullRequestRepo_CommentPullRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string)) + }) + return _c +} + +func (_c *MockPullRequestRepo_CommentPullRequest_Call) Return(_a0 error) *MockPullRequestRepo_CommentPullRequest_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPullRequestRepo_CommentPullRequest_Call) RunAndReturn(run func(context.Context, int, string) error) *MockPullRequestRepo_CommentPullRequest_Call { + _c.Call.Return(run) + return _c +} + +// CommentPullRequestFile provides a mock function with given fields: ctx, pr, path, ref, comment +func (_m *MockPullRequestRepo) CommentPullRequestFile(ctx context.Context, pr int, path string, ref string, comment string) error { + ret := _m.Called(ctx, pr, path, ref, comment) + + if len(ret) == 0 { + panic("no return value specified for CommentPullRequestFile") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, string, string, string) error); ok { + r0 = rf(ctx, pr, path, ref, comment) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPullRequestRepo_CommentPullRequestFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommentPullRequestFile' +type MockPullRequestRepo_CommentPullRequestFile_Call struct { + *mock.Call +} + +// CommentPullRequestFile is a helper method to define mock.On call +// - ctx context.Context +// - pr int +// - path string +// - ref string +// - comment string +func (_e *MockPullRequestRepo_Expecter) CommentPullRequestFile(ctx interface{}, pr interface{}, path interface{}, ref interface{}, comment interface{}) *MockPullRequestRepo_CommentPullRequestFile_Call { + return &MockPullRequestRepo_CommentPullRequestFile_Call{Call: _e.mock.On("CommentPullRequestFile", ctx, pr, path, ref, comment)} +} + +func (_c *MockPullRequestRepo_CommentPullRequestFile_Call) Run(run func(ctx context.Context, pr int, path string, ref string, comment string)) *MockPullRequestRepo_CommentPullRequestFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string), args[3].(string), args[4].(string)) + }) + return _c +} + +func (_c *MockPullRequestRepo_CommentPullRequestFile_Call) Return(_a0 error) *MockPullRequestRepo_CommentPullRequestFile_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPullRequestRepo_CommentPullRequestFile_Call) RunAndReturn(run func(context.Context, int, string, string, string) error) *MockPullRequestRepo_CommentPullRequestFile_Call { + _c.Call.Return(run) + return _c +} + +// CompareFiles provides a mock function with given fields: ctx, base, ref +func (_m *MockPullRequestRepo) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) { + ret := _m.Called(ctx, base, ref) + + if len(ret) == 0 { + panic("no return value specified for CompareFiles") + } + + var r0 []repository.VersionedFileChange + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok { + return rf(ctx, base, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok { + r0 = rf(ctx, base, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]repository.VersionedFileChange) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, base, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPullRequestRepo_CompareFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareFiles' +type MockPullRequestRepo_CompareFiles_Call struct { + *mock.Call +} + +// CompareFiles is a helper method to define mock.On call +// - ctx context.Context +// - base string +// - ref string +func (_e *MockPullRequestRepo_Expecter) CompareFiles(ctx interface{}, base interface{}, ref interface{}) *MockPullRequestRepo_CompareFiles_Call { + return &MockPullRequestRepo_CompareFiles_Call{Call: _e.mock.On("CompareFiles", ctx, base, ref)} +} + +func (_c *MockPullRequestRepo_CompareFiles_Call) Run(run func(ctx context.Context, base string, ref string)) *MockPullRequestRepo_CompareFiles_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockPullRequestRepo_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockPullRequestRepo_CompareFiles_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPullRequestRepo_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockPullRequestRepo_CompareFiles_Call { + _c.Call.Return(run) + return _c +} + +// Config provides a mock function with no fields +func (_m *MockPullRequestRepo) Config() *v0alpha1.Repository { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Config") + } + + var r0 *v0alpha1.Repository + if rf, ok := ret.Get(0).(func() *v0alpha1.Repository); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.Repository) + } + } + + return r0 +} + +// MockPullRequestRepo_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type MockPullRequestRepo_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *MockPullRequestRepo_Expecter) Config() *MockPullRequestRepo_Config_Call { + return &MockPullRequestRepo_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *MockPullRequestRepo_Config_Call) Run(run func()) *MockPullRequestRepo_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPullRequestRepo_Config_Call) Return(_a0 *v0alpha1.Repository) *MockPullRequestRepo_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPullRequestRepo_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockPullRequestRepo_Config_Call { + _c.Call.Return(run) + return _c +} + +// Read provides a mock function with given fields: ctx, path, ref +func (_m *MockPullRequestRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) { + ret := _m.Called(ctx, path, ref) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 *repository.FileInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok { + return rf(ctx, path, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok { + r0 = rf(ctx, path, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*repository.FileInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, path, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPullRequestRepo_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockPullRequestRepo_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockPullRequestRepo_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockPullRequestRepo_Read_Call { + return &MockPullRequestRepo_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)} +} + +func (_c *MockPullRequestRepo_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockPullRequestRepo_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockPullRequestRepo_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockPullRequestRepo_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPullRequestRepo_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockPullRequestRepo_Read_Call { + _c.Call.Return(run) + return _c +} + +// NewMockPullRequestRepo creates a new instance of MockPullRequestRepo. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPullRequestRepo(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPullRequestRepo { + mock := &MockPullRequestRepo{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go b/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go deleted file mode 100644 index 7a124f384cc..00000000000 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go +++ /dev/null @@ -1,192 +0,0 @@ -package pullrequest - -import ( - "bytes" - "context" - "fmt" - "html/template" - "net/url" - "path" - - "github.com/grafana/grafana-app-sdk/logging" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" -) - -// resourcePreview represents a resource that has changed in a pull request. -type resourcePreview struct { - Filename string - Path string - Action string - Kind string - OriginalURL string - OriginalScreenshotURL string - PreviewURL string - PreviewScreenshotURL string -} - -const previewsCommentTemplate = `Hey there! 🎉 -Grafana spotted some changes in your dashboard. - -{{- if and .OriginalScreenshotURL .PreviewScreenshotURL}} -### Side by Side Comparison of {{.Filename}} -| Original | Preview | -|----------|---------| -| ![Original]({{.OriginalScreenshotURL}}) | ![Preview]({{.PreviewScreenshotURL}}) | -{{- else if .OriginalScreenshotURL}} -### Original of {{.Filename}} -![Original]({{.OriginalScreenshotURL}}) -{{- else if .PreviewScreenshotURL}} -### Preview of {{.Filename}} -![Preview]({{.PreviewScreenshotURL}}) -{{ end}} - -{{ if and .OriginalURL .PreviewURL}} -See the [original]({{.OriginalURL}}) and [preview]({{.PreviewURL}}) of {{.Filename}}. -{{- else if .OriginalURL}} -See the [original]({{.OriginalURL}}) of {{.Filename}}. -{{- else if .PreviewURL}} -See the [preview]({{.PreviewURL}}) of {{.Filename}}. -{{- end}}` - -// PreviewRenderer is an interface for rendering a preview of a file -// -//go:generate mockery --name PreviewRenderer --structname MockPreviewRenderer --inpackage --filename preview_renderer_mock.go --with-expecter -type PreviewRenderer interface { - IsAvailable(ctx context.Context) bool - RenderDashboardPreview(ctx context.Context, namespace, repoName, path, ref string) (string, error) -} - -// Previewer is a service for previewing dashboard changes in a pull request -// -//go:generate mockery --name Previewer --structname MockPreviewer --inpackage --filename previewer_mock.go --with-expecter -type Previewer interface { - Preview(ctx context.Context, f repository.VersionedFileChange, namespace, repoName, base, ref, pullRequestURL string, generatePreview bool) (resourcePreview, error) - GenerateComment(preview resourcePreview) (string, error) -} - -type previewer struct { - template *template.Template - urlProvider func(namespace string) string - renderer PreviewRenderer -} - -func NewPreviewer(renderer PreviewRenderer, urlProvider func(namespace string) string) *previewer { - return &previewer{ - template: template.Must(template.New("comment").Parse(previewsCommentTemplate)), - urlProvider: urlProvider, - renderer: renderer, - } -} - -// GenerateComment creates a formatted comment for dashboard previews -func (p *previewer) GenerateComment(preview resourcePreview) (string, error) { - var buf bytes.Buffer - if err := p.template.Execute(&buf, preview); err != nil { - return "", fmt.Errorf("execute previews comment template: %w", err) - } - return buf.String(), nil -} - -// getOriginalURL returns the URL for the original version of the file based on the action -func (p *previewer) getOriginalURL(ctx context.Context, f repository.VersionedFileChange, baseURL *url.URL, repoName, base, pullRequestURL string) string { - switch f.Action { - case repository.FileActionCreated: - return "" // No original URL for new files - case repository.FileActionUpdated: - return p.previewURL(baseURL, repoName, base, f.Path, pullRequestURL) - case repository.FileActionRenamed: - return p.previewURL(baseURL, repoName, base, f.PreviousPath, pullRequestURL) - case repository.FileActionDeleted: - return p.previewURL(baseURL, repoName, base, f.Path, pullRequestURL) - default: - logging.FromContext(ctx).Error("unknown file action for original URL", "action", f.Action) - return "" - } -} - -// getPreviewURL returns the URL for the preview version of the file based on the action -func (p *previewer) getPreviewURL(ctx context.Context, f repository.VersionedFileChange, baseURL *url.URL, repoName, ref, pullRequestURL string) string { - switch f.Action { - case repository.FileActionCreated, repository.FileActionUpdated, repository.FileActionRenamed: - return p.previewURL(baseURL, repoName, ref, f.Path, pullRequestURL) - case repository.FileActionDeleted: - return "" // No preview URL for deleted files - default: - logging.FromContext(ctx).Error("unknown file action for preview URL", "action", f.Action) - return "" - } -} - -// previewURL returns the URL to preview the file in Grafana -func (p *previewer) previewURL(u *url.URL, repoName, ref, filePath, pullRequestURL string) string { - baseURL := *u - baseURL = *baseURL.JoinPath("/admin/provisioning", repoName, "dashboard/preview", filePath) - - query := baseURL.Query() - if ref != "" { - query.Set("ref", ref) - } - if pullRequestURL != "" { - query.Set("pull_request_url", url.QueryEscape(pullRequestURL)) - } - baseURL.RawQuery = query.Encode() - - return baseURL.String() -} - -// Preview creates a preview for a single file change -func (p *previewer) Preview( - ctx context.Context, - f repository.VersionedFileChange, - namespace string, - repoName string, - base string, - ref string, - pullRequestURL string, - generatePreview bool, -) (resourcePreview, error) { - baseURL, err := url.Parse(p.urlProvider(namespace)) - if err != nil { - return resourcePreview{}, fmt.Errorf("error parsing base url: %w", err) - } - - preview := resourcePreview{ - Filename: path.Base(f.Path), - Path: f.Path, - Kind: "dashboard", // TODO: add more kinds - Action: string(f.Action), - OriginalURL: p.getOriginalURL(ctx, f, baseURL, repoName, base, pullRequestURL), - PreviewURL: p.getPreviewURL(ctx, f, baseURL, repoName, ref, pullRequestURL), - } - - if !generatePreview { - logger.Info("skipping dashboard preview generation", "path", f.Path) - return preview, nil - } - - if preview.PreviewURL != "" { - screenshotURL, err := p.renderer.RenderDashboardPreview(ctx, namespace, repoName, f.Path, ref) - if err != nil { - return resourcePreview{}, fmt.Errorf("render dashboard preview: %w", err) - } - preview.PreviewScreenshotURL = screenshotURL - logger.Info("dashboard preview screenshot generated", "screenshotURL", screenshotURL) - } - - if preview.OriginalURL != "" { - originalPath := f.PreviousPath - if originalPath == "" { - originalPath = f.Path - } - - screenshotURL, err := p.renderer.RenderDashboardPreview(ctx, namespace, repoName, originalPath, base) - if err != nil { - return resourcePreview{}, fmt.Errorf("render dashboard preview: %w", err) - } - preview.OriginalScreenshotURL = screenshotURL - logger.Info("original dashboard screenshot generated", "screenshotURL", screenshotURL) - } - - return preview, nil -} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/preview_renderer_mock.go b/pkg/registry/apis/provisioning/jobs/pullrequest/preview_renderer_mock.go deleted file mode 100644 index 529430cc72c..00000000000 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/preview_renderer_mock.go +++ /dev/null @@ -1,142 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package pullrequest - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MockPreviewRenderer is an autogenerated mock type for the PreviewRenderer type -type MockPreviewRenderer struct { - mock.Mock -} - -type MockPreviewRenderer_Expecter struct { - mock *mock.Mock -} - -func (_m *MockPreviewRenderer) EXPECT() *MockPreviewRenderer_Expecter { - return &MockPreviewRenderer_Expecter{mock: &_m.Mock} -} - -// IsAvailable provides a mock function with given fields: ctx -func (_m *MockPreviewRenderer) IsAvailable(ctx context.Context) bool { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for IsAvailable") - } - - var r0 bool - if rf, ok := ret.Get(0).(func(context.Context) bool); ok { - r0 = rf(ctx) - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - -// MockPreviewRenderer_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable' -type MockPreviewRenderer_IsAvailable_Call struct { - *mock.Call -} - -// IsAvailable is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockPreviewRenderer_Expecter) IsAvailable(ctx interface{}) *MockPreviewRenderer_IsAvailable_Call { - return &MockPreviewRenderer_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)} -} - -func (_c *MockPreviewRenderer_IsAvailable_Call) Run(run func(ctx context.Context)) *MockPreviewRenderer_IsAvailable_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockPreviewRenderer_IsAvailable_Call) Return(_a0 bool) *MockPreviewRenderer_IsAvailable_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockPreviewRenderer_IsAvailable_Call) RunAndReturn(run func(context.Context) bool) *MockPreviewRenderer_IsAvailable_Call { - _c.Call.Return(run) - return _c -} - -// RenderDashboardPreview provides a mock function with given fields: ctx, namespace, repoName, path, ref -func (_m *MockPreviewRenderer) RenderDashboardPreview(ctx context.Context, namespace string, repoName string, path string, ref string) (string, error) { - ret := _m.Called(ctx, namespace, repoName, path, ref) - - if len(ret) == 0 { - panic("no return value specified for RenderDashboardPreview") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) (string, error)); ok { - return rf(ctx, namespace, repoName, path, ref) - } - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) string); ok { - r0 = rf(ctx, namespace, repoName, path, ref) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { - r1 = rf(ctx, namespace, repoName, path, ref) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockPreviewRenderer_RenderDashboardPreview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenderDashboardPreview' -type MockPreviewRenderer_RenderDashboardPreview_Call struct { - *mock.Call -} - -// RenderDashboardPreview is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - repoName string -// - path string -// - ref string -func (_e *MockPreviewRenderer_Expecter) RenderDashboardPreview(ctx interface{}, namespace interface{}, repoName interface{}, path interface{}, ref interface{}) *MockPreviewRenderer_RenderDashboardPreview_Call { - return &MockPreviewRenderer_RenderDashboardPreview_Call{Call: _e.mock.On("RenderDashboardPreview", ctx, namespace, repoName, path, ref)} -} - -func (_c *MockPreviewRenderer_RenderDashboardPreview_Call) Run(run func(ctx context.Context, namespace string, repoName string, path string, ref string)) *MockPreviewRenderer_RenderDashboardPreview_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) - }) - return _c -} - -func (_c *MockPreviewRenderer_RenderDashboardPreview_Call) Return(_a0 string, _a1 error) *MockPreviewRenderer_RenderDashboardPreview_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockPreviewRenderer_RenderDashboardPreview_Call) RunAndReturn(run func(context.Context, string, string, string, string) (string, error)) *MockPreviewRenderer_RenderDashboardPreview_Call { - _c.Call.Return(run) - return _c -} - -// NewMockPreviewRenderer creates a new instance of MockPreviewRenderer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockPreviewRenderer(t interface { - mock.TestingT - Cleanup(func()) -}) *MockPreviewRenderer { - mock := &MockPreviewRenderer{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/previewer_mock.go b/pkg/registry/apis/provisioning/jobs/pullrequest/previewer_mock.go deleted file mode 100644 index e8e8aedcdab..00000000000 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/previewer_mock.go +++ /dev/null @@ -1,156 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package pullrequest - -import ( - context "context" - - repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" - mock "github.com/stretchr/testify/mock" -) - -// MockPreviewer is an autogenerated mock type for the Previewer type -type MockPreviewer struct { - mock.Mock -} - -type MockPreviewer_Expecter struct { - mock *mock.Mock -} - -func (_m *MockPreviewer) EXPECT() *MockPreviewer_Expecter { - return &MockPreviewer_Expecter{mock: &_m.Mock} -} - -// GenerateComment provides a mock function with given fields: preview -func (_m *MockPreviewer) GenerateComment(preview resourcePreview) (string, error) { - ret := _m.Called(preview) - - if len(ret) == 0 { - panic("no return value specified for GenerateComment") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(resourcePreview) (string, error)); ok { - return rf(preview) - } - if rf, ok := ret.Get(0).(func(resourcePreview) string); ok { - r0 = rf(preview) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(resourcePreview) error); ok { - r1 = rf(preview) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockPreviewer_GenerateComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateComment' -type MockPreviewer_GenerateComment_Call struct { - *mock.Call -} - -// GenerateComment is a helper method to define mock.On call -// - preview resourcePreview -func (_e *MockPreviewer_Expecter) GenerateComment(preview interface{}) *MockPreviewer_GenerateComment_Call { - return &MockPreviewer_GenerateComment_Call{Call: _e.mock.On("GenerateComment", preview)} -} - -func (_c *MockPreviewer_GenerateComment_Call) Run(run func(preview resourcePreview)) *MockPreviewer_GenerateComment_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(resourcePreview)) - }) - return _c -} - -func (_c *MockPreviewer_GenerateComment_Call) Return(_a0 string, _a1 error) *MockPreviewer_GenerateComment_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockPreviewer_GenerateComment_Call) RunAndReturn(run func(resourcePreview) (string, error)) *MockPreviewer_GenerateComment_Call { - _c.Call.Return(run) - return _c -} - -// Preview provides a mock function with given fields: ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview -func (_m *MockPreviewer) Preview(ctx context.Context, f repository.VersionedFileChange, namespace string, repoName string, base string, ref string, pullRequestURL string, generatePreview bool) (resourcePreview, error) { - ret := _m.Called(ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview) - - if len(ret) == 0 { - panic("no return value specified for Preview") - } - - var r0 resourcePreview - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, repository.VersionedFileChange, string, string, string, string, string, bool) (resourcePreview, error)); ok { - return rf(ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview) - } - if rf, ok := ret.Get(0).(func(context.Context, repository.VersionedFileChange, string, string, string, string, string, bool) resourcePreview); ok { - r0 = rf(ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview) - } else { - r0 = ret.Get(0).(resourcePreview) - } - - if rf, ok := ret.Get(1).(func(context.Context, repository.VersionedFileChange, string, string, string, string, string, bool) error); ok { - r1 = rf(ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockPreviewer_Preview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Preview' -type MockPreviewer_Preview_Call struct { - *mock.Call -} - -// Preview is a helper method to define mock.On call -// - ctx context.Context -// - f repository.VersionedFileChange -// - namespace string -// - repoName string -// - base string -// - ref string -// - pullRequestURL string -// - generatePreview bool -func (_e *MockPreviewer_Expecter) Preview(ctx interface{}, f interface{}, namespace interface{}, repoName interface{}, base interface{}, ref interface{}, pullRequestURL interface{}, generatePreview interface{}) *MockPreviewer_Preview_Call { - return &MockPreviewer_Preview_Call{Call: _e.mock.On("Preview", ctx, f, namespace, repoName, base, ref, pullRequestURL, generatePreview)} -} - -func (_c *MockPreviewer_Preview_Call) Run(run func(ctx context.Context, f repository.VersionedFileChange, namespace string, repoName string, base string, ref string, pullRequestURL string, generatePreview bool)) *MockPreviewer_Preview_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(repository.VersionedFileChange), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].(string), args[7].(bool)) - }) - return _c -} - -func (_c *MockPreviewer_Preview_Call) Return(_a0 resourcePreview, _a1 error) *MockPreviewer_Preview_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockPreviewer_Preview_Call) RunAndReturn(run func(context.Context, repository.VersionedFileChange, string, string, string, string, string, bool) (resourcePreview, error)) *MockPreviewer_Preview_Call { - _c.Call.Return(run) - return _c -} - -// NewMockPreviewer creates a new instance of MockPreviewer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockPreviewer(t interface { - mock.TestingT - Cleanup(func()) -}) *MockPreviewer { - mock := &MockPreviewer{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/render.go b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go index cf4a6b43275..644a8660973 100644 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/render.go +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "mime" + "net/url" "os" "path/filepath" "strings" @@ -16,34 +17,45 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" ) +// ScreenshotRenderer is an interface for rendering a preview of a file +// +//go:generate mockery --name ScreenshotRenderer --structname MockScreenshotRenderer --inpackage --filename render_mock.go --with-expecter +type ScreenshotRenderer interface { + IsAvailable(ctx context.Context) bool + RenderScreenshot(ctx context.Context, repo provisioning.ResourceRepositoryInfo, path string, values url.Values) (string, error) +} + type screenshotRenderer struct { - render rendering.Service - blobstore resource.BlobStoreClient - urlProvider func(namespace string) string - isPublic bool + render rendering.Service + blobstore resource.BlobStoreClient } -func NewScreenshotRenderer(render rendering.Service, blobstore resource.BlobStoreClient, isPublic bool, urlProvider func(namespace string) string) *screenshotRenderer { +func NewScreenshotRenderer(render rendering.Service, blobstore resource.BlobStoreClient) ScreenshotRenderer { return &screenshotRenderer{ - render: render, - blobstore: blobstore, - urlProvider: urlProvider, - isPublic: isPublic, + render: render, + blobstore: blobstore, } } func (r *screenshotRenderer) IsAvailable(ctx context.Context) bool { - return r.render != nil && r.render.IsAvailable(ctx) && r.blobstore != nil && r.isPublic + return r.render != nil && r.render.IsAvailable(ctx) && r.blobstore != nil } -func (r *screenshotRenderer) RenderDashboardPreview(ctx context.Context, namespace, repoName, path, ref string) (string, error) { - url := fmt.Sprintf("admin/provisioning/%s/dashboard/preview/%s?kiosk&ref=%s", repoName, path, ref) - - // TODO: why were we using a different context? - // renderContext := identity.WithRequester(context.Background(), r.id) +func (r *screenshotRenderer) RenderScreenshot(ctx context.Context, repo provisioning.ResourceRepositoryInfo, path string, values url.Values) (string, error) { + if strings.Contains(path, "://") { + return "", fmt.Errorf("path should be relative to the system root url") + } + if strings.HasPrefix(path, "/") { + return "", fmt.Errorf("path should not start with slash") + } + if len(values) > 0 { + path = path + "?" + values.Encode() + "&kiosk" + } else { + path = path + "?kiosk" + } result, err := r.render.Render(ctx, rendering.RenderPNG, rendering.Opts{ CommonOpts: rendering.CommonOpts{ - Path: url, + Path: path, AuthOpts: rendering.AuthOpts{ OrgID: 1, // TODO!!!, use the worker identity UserID: 1, @@ -69,10 +81,10 @@ func (r *screenshotRenderer) RenderDashboardPreview(ctx context.Context, namespa rsp, err := r.blobstore.PutBlob(ctx, &resource.PutBlobRequest{ Resource: &resource.ResourceKey{ - Namespace: namespace, + Namespace: repo.Namespace, Group: provisioning.GROUP, Resource: provisioning.RepositoryResourceInfo.GroupResource().Resource, - Name: repoName, + Name: repo.Name, }, Method: resource.PutBlobRequest_GRPC, ContentType: mime.TypeByExtension(ext), // image/png @@ -84,10 +96,6 @@ func (r *screenshotRenderer) RenderDashboardPreview(ctx context.Context, namespa if rsp.Url != "" { return rsp.Url, nil } - base := r.urlProvider(namespace) - if !strings.HasSuffix(base, "/") { - base += "/" - } - return fmt.Sprintf("%sapis/%s/namespaces/%s/repositories/%s/render/%s", - base, provisioning.APIVERSION, namespace, repoName, rsp.Uid), nil + return fmt.Sprintf("apis/%s/namespaces/%s/repositories/%s/render/%s", + provisioning.APIVERSION, repo.Namespace, repo.Name, rsp.Uid), nil } diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/render_mock.go b/pkg/registry/apis/provisioning/jobs/pullrequest/render_mock.go new file mode 100644 index 00000000000..f7aa5bb5039 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/render_mock.go @@ -0,0 +1,144 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package pullrequest + +import ( + context "context" + url "net/url" + + mock "github.com/stretchr/testify/mock" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// MockScreenshotRenderer is an autogenerated mock type for the ScreenshotRenderer type +type MockScreenshotRenderer struct { + mock.Mock +} + +type MockScreenshotRenderer_Expecter struct { + mock *mock.Mock +} + +func (_m *MockScreenshotRenderer) EXPECT() *MockScreenshotRenderer_Expecter { + return &MockScreenshotRenderer_Expecter{mock: &_m.Mock} +} + +// IsAvailable provides a mock function with given fields: ctx +func (_m *MockScreenshotRenderer) IsAvailable(ctx context.Context) bool { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for IsAvailable") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockScreenshotRenderer_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable' +type MockScreenshotRenderer_IsAvailable_Call struct { + *mock.Call +} + +// IsAvailable is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockScreenshotRenderer_Expecter) IsAvailable(ctx interface{}) *MockScreenshotRenderer_IsAvailable_Call { + return &MockScreenshotRenderer_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)} +} + +func (_c *MockScreenshotRenderer_IsAvailable_Call) Run(run func(ctx context.Context)) *MockScreenshotRenderer_IsAvailable_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockScreenshotRenderer_IsAvailable_Call) Return(_a0 bool) *MockScreenshotRenderer_IsAvailable_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockScreenshotRenderer_IsAvailable_Call) RunAndReturn(run func(context.Context) bool) *MockScreenshotRenderer_IsAvailable_Call { + _c.Call.Return(run) + return _c +} + +// RenderScreenshot provides a mock function with given fields: ctx, repo, path, values +func (_m *MockScreenshotRenderer) RenderScreenshot(ctx context.Context, repo v0alpha1.ResourceRepositoryInfo, path string, values url.Values) (string, error) { + ret := _m.Called(ctx, repo, path, values) + + if len(ret) == 0 { + panic("no return value specified for RenderScreenshot") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) (string, error)); ok { + return rf(ctx, repo, path, values) + } + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) string); ok { + r0 = rf(ctx, repo, path, values) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) error); ok { + r1 = rf(ctx, repo, path, values) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockScreenshotRenderer_RenderScreenshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenderScreenshot' +type MockScreenshotRenderer_RenderScreenshot_Call struct { + *mock.Call +} + +// RenderScreenshot is a helper method to define mock.On call +// - ctx context.Context +// - repo v0alpha1.ResourceRepositoryInfo +// - path string +// - values url.Values +func (_e *MockScreenshotRenderer_Expecter) RenderScreenshot(ctx interface{}, repo interface{}, path interface{}, values interface{}) *MockScreenshotRenderer_RenderScreenshot_Call { + return &MockScreenshotRenderer_RenderScreenshot_Call{Call: _e.mock.On("RenderScreenshot", ctx, repo, path, values)} +} + +func (_c *MockScreenshotRenderer_RenderScreenshot_Call) Run(run func(ctx context.Context, repo v0alpha1.ResourceRepositoryInfo, path string, values url.Values)) *MockScreenshotRenderer_RenderScreenshot_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v0alpha1.ResourceRepositoryInfo), args[2].(string), args[3].(url.Values)) + }) + return _c +} + +func (_c *MockScreenshotRenderer_RenderScreenshot_Call) Return(_a0 string, _a1 error) *MockScreenshotRenderer_RenderScreenshot_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockScreenshotRenderer_RenderScreenshot_Call) RunAndReturn(run func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) (string, error)) *MockScreenshotRenderer_RenderScreenshot_Call { + _c.Call.Return(run) + return _c +} + +// NewMockScreenshotRenderer creates a new instance of MockScreenshotRenderer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockScreenshotRenderer(t interface { + mock.TestingT + Cleanup(func()) +}) *MockScreenshotRenderer { + mock := &MockScreenshotRenderer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/multiple-files.md b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/multiple-files.md new file mode 100755 index 00000000000..b7ce21ef16b --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/multiple-files.md @@ -0,0 +1,11 @@ +Hey there! 🎉 +Grafana spotted some changes. + +| Action | Kind | Resource | Preview | +|--------|------|----------|---------| +| create | Dashboard | Dash A | [preview](http://grafana/admin/preview) | +| update | Dashboard | [Dash B](http://grafana/d/bbb) | [preview](http://grafana/admin/preview) | +| create | Playlist | My Playlist | | + + +and 5 more files. \ No newline at end of file diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/new-dashboard.md b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/new-dashboard.md new file mode 100755 index 00000000000..deedc18c04d --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/new-dashboard.md @@ -0,0 +1,8 @@ +Hey there! 🎉 +Grafana spotted some changes to your dashboard. +### Preview of file.json +![Preview](https://cdn2.thecatapi.com/images/99c.jpg) + + + +See the [preview](http://grafana/admin/preview) of file.json. \ No newline at end of file diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard-missing-renderer.md b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard-missing-renderer.md new file mode 100755 index 00000000000..79d31c86cee --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard-missing-renderer.md @@ -0,0 +1,7 @@ +Hey there! 🎉 +Grafana spotted some changes to your dashboard. + + +See the [original](http://grafana/d/uid) and [preview](http://grafana/admin/preview) of file.json. + +NOTE: The image renderer is not configured \ No newline at end of file diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard.md b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard.md new file mode 100755 index 00000000000..2597017e685 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/testdata/update-dashboard.md @@ -0,0 +1,9 @@ +Hey there! 🎉 +Grafana spotted some changes to your dashboard. +### Side by Side Comparison of file.json +| Before | After | +|----------|---------| +| ![Before](https://cdn2.thecatapi.com/images/99c.jpg) | ![Preview](https://cdn2.thecatapi.com/images/99c.jpg) | + + +See the [original](http://grafana/d/uid) and [preview](http://grafana/admin/preview) of file.json. \ No newline at end of file diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go b/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go index 485234e2598..c95a37dbb7e 100644 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) +//go:generate mockery --name=PullRequestRepo --structname=MockPullRequestRepo --inpackage --filename=mock_pullrequest_repo.go --with-expecter type PullRequestRepo interface { Config() *provisioning.Repository Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) @@ -23,18 +24,25 @@ type PullRequestRepo interface { CommentPullRequest(ctx context.Context, pr int, comment string) error } +//go:generate mockery --name=Evaluator --structname=MockEvaluator --inpackage --filename=mock_evaluator.go --with-expecter +type Evaluator interface { + Evaluate(ctx context.Context, repo repository.Reader, opts provisioning.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error) +} + +//go:generate mockery --name=Commenter --structname=MockCommenter --inpackage --filename=mock_commenter.go --with-expecter +type Commenter interface { + Comment(ctx context.Context, repo PullRequestRepo, pr int, changeInfo changeInfo) error +} + type PullRequestWorker struct { - parsers resources.ParserFactory - previewer Previewer + evaluator Evaluator + commenter Commenter } -func NewPullRequestWorker( - parsers resources.ParserFactory, - previewer Previewer, -) *PullRequestWorker { +func NewPullRequestWorker(evaluator Evaluator, commenter Commenter) *PullRequestWorker { return &PullRequestWorker{ - parsers: parsers, - previewer: previewer, + evaluator: evaluator, + commenter: commenter, } } @@ -42,18 +50,26 @@ func (c *PullRequestWorker) IsSupported(ctx context.Context, job provisioning.Jo return job.Spec.Action == provisioning.JobActionPullRequest } -//nolint:gocyclo func (c *PullRequestWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder, ) error { cfg := repo.Config().Spec - options := job.Spec.PullRequest - if options == nil { + opts := job.Spec.PullRequest + if opts == nil { return apierrors.NewBadRequest("missing spec.pr") } + if opts.Ref == "" { + return apierrors.NewBadRequest("missing spec.ref") + } + + // FIXME: this is leaky because it's supposed to be already a PullRequestRepo + if cfg.GitHub == nil { + return apierrors.NewBadRequest("expecting github configuration") + } + prRepo, ok := repo.(PullRequestRepo) if !ok { return fmt.Errorf("repository is not a github repository") @@ -64,82 +80,54 @@ func (c *PullRequestWorker) Process(ctx context.Context, return errors.New("pull request job submitted targeting repository that is not a Reader") } - parser, err := c.parsers.GetParser(ctx, reader) - if err != nil { - return fmt.Errorf("failed to get parser for %s: %w", repo.Config().Name, err) - } - - logger := logging.FromContext(ctx).With("pr", options.PR) + logger := logging.FromContext(ctx).With("pr", opts.PR) logger.Info("process pull request") defer logger.Info("pull request processed") progress.SetMessage(ctx, "listing pull request files") + // FIXME: this is leaky because it's supposed to be already a PullRequestRepo base := cfg.GitHub.Branch - ref := options.Hash - files, err := prRepo.CompareFiles(ctx, base, ref) + files, err := prRepo.CompareFiles(ctx, base, opts.Ref) if err != nil { - return fmt.Errorf("failed to list pull request files: %s", err.Error()) - } - - progress.SetMessage(ctx, "clearing pull request comments") - if err := prRepo.ClearAllPullRequestFileComments(ctx, options.PR); err != nil { - return fmt.Errorf("failed to clear pull request comments: %+v", err) + return fmt.Errorf("failed to list pull request files: %w", err) } + files = onlySupportedFiles(files) if len(files) == 0 { progress.SetFinalMessage(ctx, "no files to process") return nil } - if len(files) > 1 { - progress.SetFinalMessage(ctx, "too many files to preview") - return nil - } - - f := files[0] - progress.SetMessage(ctx, "processing file preview") - - if err := resources.IsPathSupported(f.Path); err != nil { - progress.SetFinalMessage(ctx, "file path is not supported") - return nil - } - - fileInfo, err := prRepo.Read(ctx, f.Path, ref) + changeInfo, err := c.evaluator.Evaluate(ctx, reader, *opts, files, progress) if err != nil { - return fmt.Errorf("read file: %w", err) + return fmt.Errorf("calculate changes: %w", err) } - _, err = parser.Parse(ctx, fileInfo) - if err != nil { - if errors.Is(err, resources.ErrUnableToReadResourceBytes) { - progress.SetFinalMessage(ctx, "file changes is not valid resource") - return nil - } else { - return fmt.Errorf("parse resource: %w", err) - } - } - - // Preview should be the branch name if provided, otherwise use the commit hash - previewRef := options.Ref - if previewRef == "" { - previewRef = ref - } - - preview, err := c.previewer.Preview(ctx, f, job.Namespace, repo.Config().Name, cfg.GitHub.Branch, previewRef, options.URL, cfg.GitHub.GenerateDashboardPreviews) - if err != nil { - return fmt.Errorf("generate preview: %w", err) - } - - progress.SetMessage(ctx, "generating previews comment") - comment, err := c.previewer.GenerateComment(preview) - if err != nil { - return fmt.Errorf("generate comment: %w", err) - } - - if err := prRepo.CommentPullRequest(ctx, options.PR, comment); err != nil { + if err := c.commenter.Comment(ctx, prRepo, opts.PR, changeInfo); err != nil { return fmt.Errorf("comment pull request: %w", err) } logger.Info("preview comment added") return nil } + +// Remove files we should not try to process +func onlySupportedFiles(files []repository.VersionedFileChange) (ret []repository.VersionedFileChange) { + for _, file := range files { + if file.Action == repository.FileActionIgnored { + continue + } + + if err := resources.IsPathSupported(file.Path); err == nil { + ret = append(ret, file) + continue + } + if file.PreviousPath != "" { + if err := resources.IsPathSupported(file.PreviousPath); err != nil { + ret = append(ret, file) + continue + } + } + } + return +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 26e6e617a67..60650cfdd16 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -29,7 +29,7 @@ import ( dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" apiutils "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/apiserver/readonly" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" @@ -558,9 +558,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH ) // Pull request worker - renderer := pullrequest.NewScreenshotRenderer(b.render, b.unified, b.isPublic, b.urlProvider) - previewer := pullrequest.NewPreviewer(renderer, b.urlProvider) - pullRequestWorker := pullrequest.NewPullRequestWorker(b.parsers, previewer) + renderer := pullrequest.NewScreenshotRenderer(b.render, b.unified) + evaluator := pullrequest.NewEvaluator(renderer, b.parsers, b.urlProvider) + commenter := pullrequest.NewCommenter() + pullRequestWorker := pullrequest.NewPullRequestWorker(evaluator, commenter) driver := jobs.NewJobDriver(time.Second*28, time.Second*30, time.Second*30, b.jobs, b, b.jobHistory, exportWorker, syncWorker, migrationWorker, pullRequestWorker) @@ -1115,7 +1116,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets) } - return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn) + return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn), nil default: return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type) } diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 7da5d961409..90b64584666 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -12,12 +12,11 @@ import ( "strings" "github.com/google/go-github/v70/github" + "github.com/google/uuid" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/google/uuid" - "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" @@ -57,18 +56,14 @@ func NewGitHub( secrets secrets.Service, webhookURL string, cloneFn CloneFn, -) (*githubRepository, error) { - owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL) - if err != nil { - return nil, err - } +) *githubRepository { + owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL) token := config.Spec.GitHub.Token if token == "" { decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken) - if err != nil { - return nil, err + if err == nil { + token = string(decrypted) } - token = string(decrypted) } return &githubRepository{ config: config, @@ -78,7 +73,7 @@ func NewGitHub( owner: owner, repo: repo, cloneFn: cloneFn, - }, nil + } } func (r *githubRepository) Config() *provisioning.Repository { @@ -138,59 +133,48 @@ func parseOwnerRepo(giturl string) (owner string, repo string, err error) { return parts[1], parts[2], nil } -func fromError(err error, code int) *provisioning.TestResults { - statusErr, ok := err.(apierrors.APIStatus) - if ok { - s := statusErr.Status() - return &provisioning.TestResults{ - Code: int(s.Code), - Success: false, - Errors: []string{s.Message}, - } - } - return &provisioning.TestResults{ - Code: code, - Success: false, - Errors: []string{err.Error()}, - } -} - // Test implements provisioning.Repository. func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { if err := r.gh.IsAuthenticated(ctx); err != nil { - return fromError(err, http.StatusUnauthorized), nil + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "github", "token").String(), + Detail: err.Error(), + }}}, nil } - owner, repo, err := parseOwnerRepo(r.config.Spec.GitHub.URL) + url := r.config.Spec.GitHub.URL + owner, repo, err := parseOwnerRepo(url) if err != nil { - return fromError(err, http.StatusBadRequest), nil + return fromFieldError(field.Invalid( + field.NewPath("spec", "github", "url"), url, err.Error())), nil } // FIXME: check token permissions ok, err := r.gh.RepoExists(ctx, owner, repo) if err != nil { - return fromError(err, http.StatusBadRequest), nil + return fromFieldError(field.Invalid( + field.NewPath("spec", "github", "url"), url, err.Error())), nil } if !ok { - return &provisioning.TestResults{ - Code: http.StatusBadRequest, - Success: false, - Errors: []string{"repository does not exist"}, - }, nil + return fromFieldError(field.NotFound( + field.NewPath("spec", "github", "url"), url)), nil } - ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, r.config.Spec.GitHub.Branch) + branch := r.config.Spec.GitHub.Branch + ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, branch) if err != nil { - return fromError(err, http.StatusBadRequest), nil + return fromFieldError(field.Invalid( + field.NewPath("spec", "github", "branch"), branch, err.Error())), nil } if !ok { - return &provisioning.TestResults{ - Code: http.StatusBadRequest, - Success: false, - Errors: []string{"branch does not exist"}, - }, nil + return fromFieldError(field.NotFound( + field.NewPath("spec", "github", "branch"), branch)), nil } return &provisioning.TestResults{ diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local.go index c68da67d80f..7c5a237a94c 100644 --- a/pkg/registry/apis/provisioning/repository/local.go +++ b/pkg/registry/apis/provisioning/repository/local.go @@ -19,6 +19,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" @@ -145,36 +146,19 @@ func (r *localRepository) Validate() (fields field.ErrorList) { // Test implements provisioning.Repository. // NOTE: Validate has been called (and passed) before this function should be called func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { + path := field.NewPath("spec", "localhost", "path") if r.config.Spec.Local.Path == "" { - return &provisioning.TestResults{ - Code: http.StatusBadRequest, - Success: false, - Errors: []string{ - "no path is configured", - }, - }, nil + return fromFieldError(field.Required(path, "no path is configured")), nil } _, err := r.resolver.LocalPath(r.config.Spec.Local.Path) if err != nil { - return &provisioning.TestResults{ - Code: http.StatusBadRequest, - Success: false, - Errors: []string{ - err.Error(), - }, - }, nil + return fromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil } _, err = os.Stat(r.path) if errors.Is(err, os.ErrNotExist) { - return &provisioning.TestResults{ - Code: http.StatusBadRequest, - Success: false, - Errors: []string{ - fmt.Sprintf("directory not found: %s", r.config.Spec.Local.Path), - }, - }, nil + return fromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil } return &provisioning.TestResults{ @@ -312,18 +296,18 @@ func (r *localRepository) calculateFileHash(path string) (string, int64, error) return hex.EncodeToString(hasher.Sum(nil)), size, nil } -func (r *localRepository) Create(ctx context.Context, fpath string, ref string, data []byte, comment string) error { +func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error { if err := r.validateRequest(ref); err != nil { return err } - fpath = safepath.Join(r.path, fpath) + fpath := safepath.Join(r.path, filepath) _, err := os.Stat(fpath) if !errors.Is(err, os.ErrNotExist) { if err != nil { return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err)) } - return apierrors.NewAlreadyExists(provisioning.RepositoryResourceInfo.GroupResource(), fpath) + return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath) } if safepath.IsDir(fpath) { @@ -356,7 +340,7 @@ func (r *localRepository) Update(ctx context.Context, path string, ref string, d } if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("file does not exist") + return ErrFileNotFound } return os.WriteFile(path, data, 0600) } diff --git a/pkg/registry/apis/provisioning/repository/local_test.go b/pkg/registry/apis/provisioning/repository/local_test.go index c2825ff4f84..4fc6c6545a1 100644 --- a/pkg/registry/apis/provisioning/repository/local_test.go +++ b/pkg/registry/apis/provisioning/repository/local_test.go @@ -2,11 +2,11 @@ package repository import ( "context" - "io/fs" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" ) @@ -69,7 +69,7 @@ func TestLocalResolver(t *testing.T) { // read unknown file _, err = r.Read(context.Background(), "testdata/missing", "") - require.ErrorIs(t, err, fs.ErrNotExist) + require.True(t, apierrors.IsNotFound(err)) // 404 error _, err = r.Read(context.Background(), "testdata/webhook-push-nested.json/", "") require.Error(t, err) // not a directory diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go index db5f6a45b2a..0d15d28a9d8 100644 --- a/pkg/registry/apis/provisioning/repository/repository.go +++ b/pkg/registry/apis/provisioning/repository/repository.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" "io" - "io/fs" "net/http" "time" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" @@ -29,7 +29,12 @@ type Repository interface { } // ErrFileNotFound indicates that a path could not be found in the repository. -var ErrFileNotFound error = fs.ErrNotExist +var ErrFileNotFound error = &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + Message: "file not found", +}} type FileInfo struct { // Path to the file on disk. diff --git a/pkg/registry/apis/provisioning/repository/test.go b/pkg/registry/apis/provisioning/repository/test.go index 8ba064ea08c..76f5b4d4f67 100644 --- a/pkg/registry/apis/provisioning/repository/test.go +++ b/pkg/registry/apis/provisioning/repository/test.go @@ -6,6 +6,7 @@ import ( "net/http" "slices" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" @@ -26,10 +27,14 @@ func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestRes rsp := &provisioning.TestResults{ Code: http.StatusUnprocessableEntity, // Invalid Success: false, - Errors: make([]string, len(errors)), + Errors: make([]provisioning.ErrorDetails, len(errors)), } - for i, v := range errors { - rsp.Errors[i] = v.Error() + for i, err := range errors { + rsp.Errors[i] = provisioning.ErrorDetails{ + Type: metav1.CauseType(err.Type), + Field: err.Field, + Detail: err.Detail, + } } return rsp, nil } @@ -85,3 +90,15 @@ func ValidateRepository(repo Repository) field.ErrorList { return list } + +func fromFieldError(err *field.Error) *provisioning.TestResults { + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(err.Type), + Field: err.Field, + Detail: err.Detail, + }}, + } +} diff --git a/pkg/registry/apis/provisioning/repository/test_test.go b/pkg/registry/apis/provisioning/repository/test_test.go index 4e7b726ea56..1dd09596617 100644 --- a/pkg/registry/apis/provisioning/repository/test_test.go +++ b/pkg/registry/apis/provisioning/repository/test_test.go @@ -6,11 +6,12 @@ import ( "net/http" "testing" - provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" ) func TestValidateRepository(t *testing.T) { @@ -192,7 +193,7 @@ func TestTestRepository(t *testing.T) { name string repository *MockRepository expectedCode int - expectedErrs []string + expectedErrs []provisioning.ErrorDetails expectedError error }{ { @@ -208,7 +209,11 @@ func TestTestRepository(t *testing.T) { return m }(), expectedCode: http.StatusUnprocessableEntity, - expectedErrs: []string{"spec.title: Required value: a repository title must be given"}, + expectedErrs: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueRequired, + Field: "spec.title", + Detail: "a repository title must be given", + }}, }, { name: "test passes", @@ -257,12 +262,18 @@ func TestTestRepository(t *testing.T) { m.On("Test", mock.Anything).Return(&provisioning.TestResults{ Code: http.StatusBadRequest, Success: false, - Errors: []string{"test failed"}, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: "spec.property", + }}, }, nil) return m }(), expectedCode: http.StatusBadRequest, - expectedErrs: []string{"test failed"}, + expectedErrs: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: "spec.property", + }}, }, } diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go index a112aa3965e..6151d6064ab 100644 --- a/pkg/registry/apis/provisioning/resources/client.go +++ b/pkg/registry/apis/provisioning/resources/client.go @@ -11,7 +11,7 @@ import ( "k8s.io/client-go/dynamic" 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" iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/client" diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index ea819149165..94b6a2430ae 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -7,8 +7,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/google/uuid" - authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" @@ -48,16 +46,16 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa return nil, fmt.Errorf("parse file: %w", err) } - // Authorize the parsed resource - if err = r.authorize(ctx, parsed, utils.VerbGet); err != nil { - return nil, err - } - // Fail as we use the dry run for this response and it's not about updating the resource if err := parsed.DryRun(ctx); err != nil { return nil, fmt.Errorf("run dry run: %w", err) } + // Authorize based on the existing resource + if err = r.authorize(ctx, parsed, utils.VerbGet); err != nil { + return nil, err + } + return parsed, nil } @@ -170,6 +168,16 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, path string, ref stri // CreateResource creates a new resource in the repository func (r *DualReadWriter) CreateResource(ctx context.Context, path string, ref string, message string, data []byte) (*ParsedResource, error) { + return r.createOrUpdate(ctx, true, path, ref, message, data) +} + +// UpdateResource updates a resource in the repository +func (r *DualReadWriter) UpdateResource(ctx context.Context, path string, ref string, message string, data []byte) (*ParsedResource, error) { + return r.createOrUpdate(ctx, false, path, ref, message, data) +} + +// Create or updates a resource in the repository +func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, path string, ref string, message string, data []byte) (*ParsedResource, error) { if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil { return nil, err } @@ -182,145 +190,110 @@ func (r *DualReadWriter) CreateResource(ctx context.Context, path string, ref st parsed, err := r.parser.Parse(ctx, info) if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - if err = r.authorize(ctx, parsed, utils.VerbCreate); err != nil { return nil, err } - data, err = parsed.ToSaveBytes() - if err != nil { - return nil, err - } + // Make sure the value is valid + if err := parsed.DryRun(ctx); err != nil { + logger := logging.FromContext(ctx).With("path", path, "name", parsed.Obj.GetName(), "ref", ref) + logger.Warn("failed to dry run resource on create", "error", err) - if err := r.repo.Create(ctx, path, ref, data, message); err != nil { - return nil, fmt.Errorf("create resource in repository: %w", err) + // TODO: return this as a 400 rather than 500 + return nil, fmt.Errorf("error running dryRun %w", err) } - // Directly update the grafana database - // Behaves the same running sync after writing - // FIXME: to make sure if behaves in the same way as in sync, we should - // we should refactor the code to use the same function. - if ref == "" { - if _, err := r.folders.EnsureFolderPathExist(ctx, path); err != nil { - return nil, fmt.Errorf("ensure folder path exists: %w", err) - } - - if err := parsed.Run(ctx); err != nil { - return nil, fmt.Errorf("run resource: %w", err) - } - } else { - if err := parsed.DryRun(ctx); err != nil { - logger := logging.FromContext(ctx).With("path", path, "name", parsed.Obj.GetName(), "ref", ref) - logger.Warn("failed to dry run resource on create", "error", err) - // Do not fail here as it's purely informational - parsed.Errors = append(parsed.Errors, err.Error()) - } + if len(parsed.Errors) > 0 { + // TODO: return this as a 400 rather than 500 + return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors) } - return parsed, nil -} - -// UpdateResource updates a resource in the repository -func (r *DualReadWriter) UpdateResource(ctx context.Context, path string, ref string, message string, data []byte) (*ParsedResource, error) { - if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil { - return nil, err + // Verify that we can create (or update) the referenced resource + verb := utils.VerbUpdate + if parsed.Action == provisioning.ResourceActionCreate { + verb = utils.VerbCreate } - - info := &repository.FileInfo{ - Data: data, - Path: path, - Ref: ref, + if err = r.authorize(ctx, parsed, verb); err != nil { + return nil, err } - // TODO: improve parser to parse out of reader - parsed, err := r.parser.Parse(ctx, info) + data, err = parsed.ToSaveBytes() if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - if err = r.authorize(ctx, parsed, utils.VerbUpdate); err != nil { return nil, err } - data, err = parsed.ToSaveBytes() + // Always use the provisioning identity when writing + ctx, _, err = identity.WithProvisioningIdentity(ctx, parsed.Obj.GetNamespace()) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to use provisioning identity %w", err) } - if err = r.repo.Update(ctx, path, ref, data, message); err != nil { - return nil, fmt.Errorf("update resource in repository: %w", err) + // Create or update + if create { + err = r.repo.Create(ctx, path, ref, data, message) + } else { + err = r.repo.Update(ctx, path, ref, data, message) + } + if err != nil { + return nil, err // raw error is useful } // Directly update the grafana database // Behaves the same running sync after writing // FIXME: to make sure if behaves in the same way as in sync, we should // we should refactor the code to use the same function. - if ref == "" { + if ref == "" && parsed.Client != nil { if _, err := r.folders.EnsureFolderPathExist(ctx, path); err != nil { return nil, fmt.Errorf("ensure folder path exists: %w", err) } - if err := parsed.Run(ctx); err != nil { - return nil, fmt.Errorf("run resource: %w", err) - } - } else { - if err := parsed.DryRun(ctx); err != nil { - // Do not fail here as it's purely informational - logger := logging.FromContext(ctx).With("path", path, "name", parsed.Obj.GetName(), "ref", ref) - logger.Warn("failed to dry run resource on update", "error", err) - parsed.Errors = append(parsed.Errors, err.Error()) - } + err = parsed.Run(ctx) } - return parsed, nil + return parsed, err } func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error { - auth, ok := authlib.AuthInfoFrom(ctx) - if !ok { - return fmt.Errorf("missing auth info in context") - } - rsp, err := r.access.Check(ctx, auth, authlib.CheckRequest{ - Group: parsed.GVR.Group, - Resource: parsed.GVR.Resource, - Namespace: parsed.Obj.GetNamespace(), - Name: parsed.Obj.GetName(), - Folder: parsed.Meta.GetFolder(), - Verb: verb, - }) + id, err := identity.GetRequester(ctx) if err != nil { - return err + return apierrors.NewUnauthorized(err.Error()) + } + + // Use configured permissions for get+delete + if parsed.Existing != nil && (verb == utils.VerbGet || verb == utils.VerbDelete) { + rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{ + Group: parsed.GVR.Group, + Resource: parsed.GVR.Resource, + Namespace: parsed.Existing.GetNamespace(), + Name: parsed.Existing.GetName(), + Folder: parsed.Meta.GetFolder(), + Verb: utils.VerbGet, + }) + if err != nil || !rsp.Allowed { + return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), + fmt.Errorf("no access to read the embedded file")) + } } - if !rsp.Allowed { - return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), - fmt.Errorf("no access to see embedded file")) + + // Simple role based access for now + if id.GetOrgRole().Includes(identity.RoleEditor) { + return nil } - return nil + + return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), + fmt.Errorf("must be admin or editor to access files from provisioning")) } func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) error { - auth, ok := authlib.AuthInfoFrom(ctx) - if !ok { - return fmt.Errorf("missing auth info in context") - } - rsp, err := r.access.Check(ctx, auth, authlib.CheckRequest{ - Group: FolderResource.Group, - Resource: FolderResource.Resource, - Namespace: r.repo.Config().GetNamespace(), - Verb: utils.VerbCreate, - - // TODO: Currently this checks if you can create a new folder in root - // Ideally we should check the path and use the explicit parent and new id - Name: "f" + uuid.NewString(), - }) + id, err := identity.GetRequester(ctx) if err != nil { - return err + return apierrors.NewUnauthorized(err.Error()) } - if !rsp.Allowed { - return apierrors.NewForbidden(FolderResource.GroupResource(), "", - fmt.Errorf("unable to create folder resource")) + + // Simple role based access for now + if id.GetOrgRole().Includes(identity.RoleEditor) { + return nil } - return nil + + return apierrors.NewForbidden(FolderResource.GroupResource(), "", + fmt.Errorf("must be admin or editor to access folders with provisioning")) } diff --git a/pkg/registry/apis/provisioning/resources/fileformat.go b/pkg/registry/apis/provisioning/resources/fileformat.go index dfff81efa62..3c9d9841bf7 100644 --- a/pkg/registry/apis/provisioning/resources/fileformat.go +++ b/pkg/registry/apis/provisioning/resources/fileformat.go @@ -37,7 +37,7 @@ func ReadClassicResource(ctx context.Context, info *repository.FileInfo) (*unstr return nil, nil, "", err } } else { - return nil, nil, "", fmt.Errorf("classic resource must be JSON") + return nil, nil, "", fmt.Errorf("unable to read file") } // regular version headers exist @@ -64,7 +64,7 @@ func ReadClassicResource(ctx context.Context, info *repository.FileInfo) (*unstr value["tags"] != nil { gvk := &schema.GroupVersionKind{ Group: dashboard.GROUP, - Version: dashboard.VERSION, // v1 + Version: "v0alpha1", // no schema Kind: "Dashboard"} return &unstructured.Unstructured{ Object: map[string]interface{}{ diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index 6947ba44b95..0fbd8fa36ed 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -12,7 +12,7 @@ import ( "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/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" ) @@ -119,8 +119,8 @@ func (fm *FolderManager) EnsureFolderExists(ctx context.Context, folder Folder, }, }, } - obj.SetAPIVersion(v0alpha1.APIVERSION) - obj.SetKind(v0alpha1.FolderResourceInfo.GroupVersionKind().Kind) + obj.SetAPIVersion(folders.APIVERSION) + obj.SetKind(folders.FolderResourceInfo.GroupVersionKind().Kind) obj.SetNamespace(cfg.GetNamespace()) obj.SetName(folder.ID) diff --git a/pkg/registry/apis/provisioning/resources/object.go b/pkg/registry/apis/provisioning/resources/object.go index 27df4be5cb6..eb62fa7e92d 100644 --- a/pkg/registry/apis/provisioning/resources/object.go +++ b/pkg/registry/apis/provisioning/resources/object.go @@ -8,7 +8,7 @@ import ( dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "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/dashboard/legacy" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" diff --git a/pkg/registry/apis/provisioning/resources/parser.go b/pkg/registry/apis/provisioning/resources/parser.go index 505ed7ba996..0601a9f1e17 100644 --- a/pkg/registry/apis/provisioning/resources/parser.go +++ b/pkg/registry/apis/provisioning/resources/parser.go @@ -138,7 +138,7 @@ func (r *parser) Parse(ctx context.Context, info *repository.FileInfo) (parsed * logger.Debug("failed to find GVK of the input data, trying fallback loader", "error", err) parsed.Obj, gvk, parsed.Classic, err = ReadClassicResource(ctx, info) if err != nil || gvk == nil { - return nil, err + return nil, apierrors.NewBadRequest("unable to read file as a resource") } } @@ -212,6 +212,10 @@ func (r *parser) Parse(ctx context.Context, info *repository.FileInfo) (parsed * } func (f *ParsedResource) DryRun(ctx context.Context) error { + if f.DryRunResponse != nil { + return nil // this already ran (and helpful for testing) + } + // FIXME: remove this check once we have better unit tests if f.Client == nil { return fmt.Errorf("no client configured") @@ -223,18 +227,25 @@ func (f *ParsedResource) DryRun(ctx context.Context) error { return err } + fieldValidation := "Strict" + if f.GVR == DashboardResource { + fieldValidation = "Ignore" // FIXME: temporary while we improve validation + } + // FIXME: shouldn't we check for the specific error? // Dry run CREATE or UPDATE f.Existing, _ = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) if f.Existing == nil { f.Action = provisioning.ResourceActionCreate f.DryRunResponse, err = f.Client.Create(ctx, f.Obj, metav1.CreateOptions{ - DryRun: []string{"All"}, + DryRun: []string{"All"}, + FieldValidation: fieldValidation, }) } else { f.Action = provisioning.ResourceActionUpdate f.DryRunResponse, err = f.Client.Update(ctx, f.Obj, metav1.UpdateOptions{ - DryRun: []string{"All"}, + DryRun: []string{"All"}, + FieldValidation: fieldValidation, }) } return err @@ -252,15 +263,28 @@ func (f *ParsedResource) Run(ctx context.Context) error { return err } - // FIXME: shouldn't we check for the specific error? + // We may have already called DryRun that also calls get + if f.DryRunResponse != nil && f.Action != "" { + // FIXME: shouldn't we check for the specific error? + f.Existing, _ = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) + } + + fieldValidation := "Strict" + if f.GVR == DashboardResource { + fieldValidation = "Ignore" // FIXME: temporary while we improve validation + } + // Run update or create - f.Existing, _ = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) if f.Existing == nil { f.Action = provisioning.ResourceActionCreate - f.Upsert, err = f.Client.Create(ctx, f.Obj, metav1.CreateOptions{}) + f.Upsert, err = f.Client.Create(ctx, f.Obj, metav1.CreateOptions{ + FieldValidation: fieldValidation, + }) } else { f.Action = provisioning.ResourceActionUpdate - f.Upsert, err = f.Client.Update(ctx, f.Obj, metav1.UpdateOptions{}) + f.Upsert, err = f.Client.Update(ctx, f.Obj, metav1.UpdateOptions{ + FieldValidation: fieldValidation, + }) } return err diff --git a/pkg/registry/apis/provisioning/resources/parser_test.go b/pkg/registry/apis/provisioning/resources/parser_test.go index 85b509eae3d..bf545f90b07 100644 --- a/pkg/registry/apis/provisioning/resources/parser_test.go +++ b/pkg/registry/apis/provisioning/resources/parser_test.go @@ -34,7 +34,7 @@ func TestParser(t *testing.T) { Data: []byte("hello"), // not a real resource }) require.Error(t, err) - require.Equal(t, "classic resource must be JSON", err.Error()) + require.Equal(t, "unable to read file as a resource", err.Error()) }) t.Run("dashboard parsing (with and without name)", func(t *testing.T) { @@ -56,7 +56,7 @@ spec: // Now try again without a name _, err = parser.Parse(context.Background(), &repository.FileInfo{ - Data: []byte(`apiVersion: dashboard.grafana.app/v1alpha1 + Data: []byte(`apiVersion: ` + dashboardV1.APIVERSION + ` kind: Dashboard spec: title: Test dashboard diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go index 26d79bf4485..7836581ddd1 100644 --- a/pkg/registry/apis/provisioning/resources/resources.go +++ b/pkg/registry/apis/provisioning/resources/resources.go @@ -177,10 +177,20 @@ func (r *ResourcesManager) WriteResourceFromFile(ctx context.Context, path strin parsed.Meta.SetUID("") parsed.Meta.SetResourceVersion("") + // TODO: use parsed.Run() (but that has an extra GET now!!) + fieldValidation := "Strict" + if parsed.GVR == DashboardResource { + fieldValidation = "Ignore" // FIXME: temporary while we improve validation + } + // Update or Create resource - parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{}) + parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{ + FieldValidation: fieldValidation, + }) if apierrors.IsNotFound(err) { - parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{}) + parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{ + FieldValidation: fieldValidation, + }) } return parsed.Obj.GetName(), parsed.GVK, err diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go index 85a091459ae..4efd0e82d57 100644 --- a/pkg/registry/apis/provisioning/resources/tree.go +++ b/pkg/registry/apis/provisioning/resources/tree.go @@ -8,7 +8,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "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/safepath" ) diff --git a/pkg/registry/apis/provisioning/test.go b/pkg/registry/apis/provisioning/test.go index 85c8b3a2bf8..8197b00a96d 100644 --- a/pkg/registry/apis/provisioning/test.go +++ b/pkg/registry/apis/provisioning/test.go @@ -99,9 +99,9 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio if res == nil { res = &provisioning.TestResults{ Success: false, - Errors: []string{ - "missing health status", - }, + Errors: []provisioning.ErrorDetails{{ + Detail: "missing health status", + }}, } } @@ -109,7 +109,11 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio repo.Status.Health = provisioning.HealthStatus{ Healthy: res.Success, Checked: time.Now().UnixMilli(), - Message: res.Errors, + } + for _, err := range res.Errors { + if err.Detail != "" { + repo.Status.Health.Message = append(repo.Status.Health.Message, err.Detail) + } } _, err := t.client.Repositories(repo.GetNamespace()). diff --git a/pkg/services/apiserver/standalone/runtime_test.go b/pkg/services/apiserver/standalone/runtime_test.go index d5c756031d3..dbee0a54e2b 100644 --- a/pkg/services/apiserver/standalone/runtime_test.go +++ b/pkg/services/apiserver/standalone/runtime_test.go @@ -4,11 +4,12 @@ import ( "fmt" "testing" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/stretchr/testify/require" ) func TestReadRuntimeCOnfig(t *testing.T) { - out, err := ReadRuntimeConfig("all/all=true,dashboard.grafana.app/v1alpha1=false") + out, err := ReadRuntimeConfig("all/all=true," + dashboardv1.APIVERSION + "=false") require.NoError(t, err) require.Equal(t, []RuntimeConfig{ {Group: "all", Version: "all", Enabled: true}, diff --git a/pkg/services/authn/authnimpl/service_test.go b/pkg/services/authn/authnimpl/service_test.go index 3decba02de4..bd01e07456a 100644 --- a/pkg/services/authn/authnimpl/service_test.go +++ b/pkg/services/authn/authnimpl/service_test.go @@ -307,7 +307,7 @@ func TestService_OrgID(t *testing.T) { desc: "should set org id from default namespace", req: &authn.Request{HTTPRequest: &http.Request{ Header: map[string][]string{}, - URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/default/folders"), + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v1/namespaces/default/folders"), }}, expectedOrgID: 1, }, @@ -315,7 +315,7 @@ func TestService_OrgID(t *testing.T) { desc: "should set org id from namespace", req: &authn.Request{HTTPRequest: &http.Request{ Header: map[string][]string{}, - URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/org-2/folders"), + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v1/namespaces/org-2/folders"), }}, expectedOrgID: 2, }, @@ -323,7 +323,7 @@ func TestService_OrgID(t *testing.T) { desc: "should set set org 1 for stack namespace", req: &authn.Request{HTTPRequest: &http.Request{ Header: map[string][]string{}, - URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/stacks-100/folders"), + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v1/namespaces/stacks-100/folders"), }}, stackID: 100, expectedOrgID: 1, @@ -332,7 +332,7 @@ func TestService_OrgID(t *testing.T) { desc: "should error for wrong stack namespace", req: &authn.Request{HTTPRequest: &http.Request{ Header: map[string][]string{}, - URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/stacks-100/folders"), + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v1/namespaces/stacks-100/folders"), }}, stackID: 101, expectedOrgID: 0, diff --git a/pkg/services/authz/rbac.go b/pkg/services/authz/rbac.go index d0ce26f0a9e..e15603b7461 100644 --- a/pkg/services/authz/rbac.go +++ b/pkg/services/authz/rbac.go @@ -8,11 +8,6 @@ import ( "time" "github.com/fullstorydev/grpchan/inprocgrpc" - authnlib "github.com/grafana/authlib/authn" - authzlib "github.com/grafana/authlib/authz" - authzv1 "github.com/grafana/authlib/authz/proto/v1" - "github.com/grafana/authlib/cache" - authlib "github.com/grafana/authlib/types" grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" @@ -21,6 +16,11 @@ import ( "google.golang.org/grpc/credentials/insecure" "k8s.io/client-go/rest" + authnlib "github.com/grafana/authlib/authn" + authzlib "github.com/grafana/authlib/authz" + authzv1 "github.com/grafana/authlib/authz/proto/v1" + "github.com/grafana/authlib/cache" + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -60,6 +60,13 @@ func ProvideAuthZClient( return nil, errors.New("authZGRPCServer feature toggle is required for cloud and grpc mode") } + // Provisioning uses mode 4 (read+write only to unified storage) + // For G12 launch, we can disable caching for this and find a more scalable solution soon + // most likely this would involve passing the RV (timestamp!) in each check method + if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + authCfg.cacheTTL = 0 + } + switch authCfg.mode { case clientModeCloud: rbacClient, err := newRemoteRBACClient(authCfg, tracer) @@ -70,7 +77,7 @@ func ProvideAuthZClient( default: sql := legacysql.NewDatabaseProvider(db) - rbacSettings := rbac.Settings{} + rbacSettings := rbac.Settings{CacheTTL: authCfg.cacheTTL} if cfg != nil { rbacSettings.AnonOrgRole = cfg.Anonymous.OrgRole } @@ -163,14 +170,16 @@ func newRemoteRBACClient(clientCfg *authzClientSettings, tracer trace.Tracer) (a return nil, fmt.Errorf("failed to create authz client to remote server: %w", err) } - client := authzlib.NewClient( - conn, - authzlib.WithCacheClientOption(cache.NewLocalCache(cache.Config{ - Expiry: 30 * time.Second, + // Client side cache + var authzCache cache.Cache = &NoopCache{} + if clientCfg.cacheTTL != 0 { + authzCache = cache.NewLocalCache(cache.Config{ + Expiry: clientCfg.cacheTTL, CleanupInterval: 2 * time.Minute, - })), - authzlib.WithTracerClientOption(tracer), - ) + }) + } + + client := authzlib.NewClient(conn, authzlib.WithCacheClientOption(authzCache), authzlib.WithTracerClientOption(tracer)) return client, nil } @@ -215,7 +224,7 @@ func RegisterRBACAuthZService( tracer, reg, cache, - rbac.Settings{}, // anonymous org role can only be set in-proc + rbac.Settings{CacheTTL: cfg.CacheTTL}, // anonymous org role can only be set in-proc ) srv := handler.GetServer() diff --git a/pkg/services/authz/rbac/cache.go b/pkg/services/authz/rbac/cache.go index a8a67ec43cb..5a37051c499 100644 --- a/pkg/services/authz/rbac/cache.go +++ b/pkg/services/authz/rbac/cache.go @@ -43,7 +43,11 @@ func folderCacheKey(namespace string) string { return namespace + ".folders" } -type cacheWrap[T any] struct { +type cacheWrap[T any] interface { + Get(ctx context.Context, key string) (T, bool) + Set(ctx context.Context, key string, value T) +} +type cacheWrapImpl[T any] struct { cache cache.Cache logger log.Logger ttl time.Duration @@ -51,11 +55,15 @@ type cacheWrap[T any] struct { // cacheWrap is a wrapper around the authlib Cache that provides typed Get and Set methods // it handles encoding/decoding for a specific type. -func newCacheWrap[T any](cache cache.Cache, logger log.Logger, ttl time.Duration) *cacheWrap[T] { - return &cacheWrap[T]{cache: cache, logger: logger, ttl: ttl} +func newCacheWrap[T any](cache cache.Cache, logger log.Logger, ttl time.Duration) cacheWrap[T] { + if ttl == 0 { + logger.Info("cache ttl is 0, using noop cache") + return &noopCache[T]{} + } + return &cacheWrapImpl[T]{cache: cache, logger: logger, ttl: ttl} } -func (c *cacheWrap[T]) Get(ctx context.Context, key string) (T, bool) { +func (c *cacheWrapImpl[T]) Get(ctx context.Context, key string) (T, bool) { logger := c.logger.FromContext(ctx) var value T @@ -76,7 +84,7 @@ func (c *cacheWrap[T]) Get(ctx context.Context, key string) (T, bool) { return value, true } -func (c *cacheWrap[T]) Set(ctx context.Context, key string, value T) { +func (c *cacheWrapImpl[T]) Set(ctx context.Context, key string, value T) { logger := c.logger.FromContext(ctx) data, err := json.Marshal(value) @@ -90,3 +98,14 @@ func (c *cacheWrap[T]) Set(ctx context.Context, key string, value T) { logger.Warn("failed to set to cache", "key", key, "error", err) } } + +type noopCache[T any] struct{} + +func (lc *noopCache[T]) Get(ctx context.Context, key string) (T, bool) { + var value T + return value, false +} + +func (lc *noopCache[T]) Set(ctx context.Context, key string, value T) { + // no-op +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 46a24006fdf..f805197d292 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -54,16 +54,19 @@ type Service struct { sf *singleflight.Group // Cache for user permissions, user team memberships and user basic roles - idCache *cacheWrap[store.UserIdentifiers] - permCache *cacheWrap[map[string]bool] - permDenialCache *cacheWrap[bool] - teamCache *cacheWrap[[]int64] - basicRoleCache *cacheWrap[store.BasicRole] - folderCache *cacheWrap[folderTree] + idCache cacheWrap[store.UserIdentifiers] + permCache cacheWrap[map[string]bool] + permDenialCache cacheWrap[bool] + teamCache cacheWrap[[]int64] + basicRoleCache cacheWrap[store.BasicRole] + folderCache cacheWrap[folderTree] } type Settings struct { AnonOrgRole string + // CacheTTL is the time to live for the permission cache entries. + // Set to 0 to disable caching. + CacheTTL time.Duration } func NewService( @@ -91,11 +94,11 @@ func NewService( metrics: newMetrics(reg), mapper: newMapper(), idCache: newCacheWrap[store.UserIdentifiers](cache, logger, longCacheTTL), - permCache: newCacheWrap[map[string]bool](cache, logger, shortCacheTTL), - permDenialCache: newCacheWrap[bool](cache, logger, shortCacheTTL), - teamCache: newCacheWrap[[]int64](cache, logger, shortCacheTTL), - basicRoleCache: newCacheWrap[store.BasicRole](cache, logger, shortCacheTTL), - folderCache: newCacheWrap[folderTree](cache, logger, shortCacheTTL), + permCache: newCacheWrap[map[string]bool](cache, logger, settings.CacheTTL), + permDenialCache: newCacheWrap[bool](cache, logger, settings.CacheTTL), + teamCache: newCacheWrap[[]int64](cache, logger, settings.CacheTTL), + basicRoleCache: newCacheWrap[store.BasicRole](cache, logger, settings.CacheTTL), + folderCache: newCacheWrap[folderTree](cache, logger, settings.CacheTTL), sf: new(singleflight.Group), } } diff --git a/pkg/services/authz/rbac/store/folder_store.go b/pkg/services/authz/rbac/store/folder_store.go index a8ed3d2a1f0..f61456c7261 100644 --- a/pkg/services/authz/rbac/store/folder_store.go +++ b/pkg/services/authz/rbac/store/folder_store.go @@ -12,7 +12,7 @@ import ( "k8s.io/client-go/tools/pager" "github.com/grafana/grafana/pkg/apimachinery/utils" - folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" @@ -158,5 +158,5 @@ func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic. if err != nil { return nil, err } - return client.Resource(folderv0alpha1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil + return client.Resource(folderv1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil } diff --git a/pkg/services/authz/rbac_settings.go b/pkg/services/authz/rbac_settings.go index ea0417e5041..a4a48a2e7f1 100644 --- a/pkg/services/authz/rbac_settings.go +++ b/pkg/services/authz/rbac_settings.go @@ -2,6 +2,7 @@ package authz import ( "fmt" + "time" "github.com/grafana/grafana/pkg/setting" ) @@ -29,6 +30,8 @@ type authzClientSettings struct { token string tokenExchangeURL string tokenNamespace string + + cacheTTL time.Duration } func readAuthzClientSettings(cfg *setting.Cfg) (*authzClientSettings, error) { @@ -41,6 +44,9 @@ func readAuthzClientSettings(cfg *setting.Cfg) (*authzClientSettings, error) { } s := &authzClientSettings{} + // Cache duration applies to the server cache in proc, so it's relevant for both modes. + s.cacheTTL = authzSection.Key("cache_ttl").MustDuration(30 * time.Second) + s.mode = mode if s.mode == clientModeInproc { return s, nil @@ -48,6 +54,7 @@ func readAuthzClientSettings(cfg *setting.Cfg) (*authzClientSettings, error) { s.remoteAddress = authzSection.Key("remote_address").MustString("") s.certFile = authzSection.Key("cert_file").MustString("") + s.token = grpcClientAuthSection.Key("token").MustString("") s.tokenNamespace = grpcClientAuthSection.Key("token_namespace").MustString("stacks-" + cfg.StackID) s.tokenExchangeURL = grpcClientAuthSection.Key("token_exchange_url").MustString("") @@ -61,7 +68,8 @@ func readAuthzClientSettings(cfg *setting.Cfg) (*authzClientSettings, error) { } type RBACServerSettings struct { - Folder FolderAPISettings + Folder FolderAPISettings + CacheTTL time.Duration } type FolderAPISettings struct { diff --git a/pkg/services/authz/zanzana/common/info.go b/pkg/services/authz/zanzana/common/info.go index 9506a8ac8d9..dbf228883cc 100644 --- a/pkg/services/authz/zanzana/common/info.go +++ b/pkg/services/authz/zanzana/common/info.go @@ -4,7 +4,7 @@ import ( authzv1 "github.com/grafana/authlib/authz/proto/v1" "google.golang.org/protobuf/types/known/structpb" - folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + folders "github.com/grafana/grafana/pkg/apis/folder/v1" iamalpha1 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" ) @@ -16,8 +16,8 @@ type typeInfo struct { var typedResources = map[string]typeInfo{ FormatGroupResource( - folderalpha1.FolderResourceInfo.GroupResource().Group, - folderalpha1.FolderResourceInfo.GroupResource().Resource, + folders.FolderResourceInfo.GroupResource().Group, + folders.FolderResourceInfo.GroupResource().Resource, "", ): {Type: "folder", Relations: RelationsTyped}, FormatGroupResource( diff --git a/pkg/services/authz/zanzana/translations.go b/pkg/services/authz/zanzana/translations.go index 457c75bdd82..f7874b50308 100644 --- a/pkg/services/authz/zanzana/translations.go +++ b/pkg/services/authz/zanzana/translations.go @@ -1,8 +1,8 @@ package zanzana import ( - dashboardalpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" - folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + dashboards "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + folders "github.com/grafana/grafana/pkg/apis/folder/v1" ) const ( @@ -44,11 +44,11 @@ func newScopedMapping(relation, group, resource, subresource string) actionMappi } var ( - folderGroup = folderalpha1.FolderResourceInfo.GroupResource().Group - folderResource = folderalpha1.FolderResourceInfo.GroupResource().Resource + folderGroup = folders.FolderResourceInfo.GroupResource().Group + folderResource = folders.FolderResourceInfo.GroupResource().Resource - dashboardGroup = dashboardalpha1.DashboardResourceInfo.GroupResource().Group - dashboardResource = dashboardalpha1.DashboardResourceInfo.GroupResource().Resource + dashboardGroup = dashboards.DashboardResourceInfo.GroupResource().Group + dashboardResource = dashboards.DashboardResourceInfo.GroupResource().Resource ) var resourceTranslations = map[string]resourceTranslation{ diff --git a/pkg/services/dashboards/service/client/client.go b/pkg/services/dashboards/service/client/client.go index bea26198844..7a3404b50f8 100644 --- a/pkg/services/dashboards/service/client/client.go +++ b/pkg/services/dashboards/service/client/client.go @@ -10,7 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apiserver" @@ -47,7 +47,7 @@ func NewK8sClientWithFallback( ) *K8sClientWithFallback { newClientFunc := newK8sClientFactory(cfg, restConfigProvider, dashboardStore, userService, resourceClient, sorter, dual) return &K8sClientWithFallback{ - K8sHandler: newClientFunc(context.Background(), dashboardv1alpha1.VERSION), + K8sHandler: newClientFunc(context.Background(), dashboardv1.VERSION), newClientFunc: newClientFunc, metrics: newK8sClientMetrics(reg), log: log.New("dashboards-k8s-client"), @@ -117,7 +117,7 @@ func newK8sClientFactory( cacheMutex := &sync.RWMutex{} return func(ctx context.Context, version string) client.K8sHandler { _, span := tracing.Start(ctx, "k8sClientFactory.GetClient", - attribute.String("group", dashboardv1alpha1.GROUP), + attribute.String("group", dashboardv1.GROUP), attribute.String("version", version), attribute.String("resource", "dashboards"), ) @@ -143,7 +143,7 @@ func newK8sClientFactory( } gvr := schema.GroupVersionResource{ - Group: dashboardv1alpha1.GROUP, + Group: dashboardv1.GROUP, Version: version, Resource: "dashboards", } diff --git a/pkg/services/dashboards/service/client/client_test.go b/pkg/services/dashboards/service/client/client_test.go index 561f9da150a..767f89ad1f5 100644 --- a/pkg/services/dashboards/service/client/client_test.go +++ b/pkg/services/dashboards/service/client/client_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/client" ) @@ -38,7 +38,7 @@ func setupTest(t *testing.T) *testSetup { if version == "v2alpha1" { return mockClientV2Alpha1 } - if version == dashboardv1alpha1.VERSION { + if version == dashboardv1.VERSION { return mockClientV1Alpha1 } t.Fatalf("Unexpected call to newClientFunc with version %s", version) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index aa718cafbfd..eed74193348 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -26,11 +26,11 @@ import ( "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" - dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + dashboardv1 "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" + folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" @@ -1553,7 +1553,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb result.SortMeta = hit.Field.GetNestedInt64(fieldName) } - if hit.Resource == folderv0alpha1.RESOURCE { + if hit.Resource == folderv1.RESOURCE { result.IsFolder = true } @@ -1566,7 +1566,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb return dr.dashboardStore.FindDashboards(ctx, query) } -func (dr *DashboardServiceImpl) fetchFolderNames(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, hits []dashboardv0alpha1.DashboardHit) (map[string]string, error) { +func (dr *DashboardServiceImpl) fetchFolderNames(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, hits []dashboardv0.DashboardHit) (map[string]string, error) { // call this with elevated permissions so we can get folder names where user does not have access // some dashboards are shared directly with user, but the folder is not accessible via the folder permissions serviceCtx, serviceIdent := identity.WithServiceIdentity(ctx, query.OrgId) @@ -1923,7 +1923,7 @@ func (dr *DashboardServiceImpl) listDashboardsThroughK8s(ctx context.Context, or return dashboards, nil } -func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (dashboardv0alpha1.SearchResults, error) { +func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (dashboardv0.SearchResults, error) { request := &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Fields: []*resource.Requirement{}, @@ -2053,21 +2053,21 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex switch query.Type { case "": // When no type specified, search for dashboards - request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv1alpha1.DASHBOARD_RESOURCE) + request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv1.DASHBOARD_RESOURCE) // Currently a search query is across folders and dashboards if err == nil { - federate, err = resource.AsResourceKey(namespace, folderv0alpha1.RESOURCE) + federate, err = resource.AsResourceKey(namespace, folderv1.RESOURCE) } case searchstore.TypeDashboard, searchstore.TypeAnnotation: - request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv1alpha1.DASHBOARD_RESOURCE) + request.Options.Key, err = resource.AsResourceKey(namespace, dashboardv1.DASHBOARD_RESOURCE) case searchstore.TypeFolder, searchstore.TypeAlertFolder: - request.Options.Key, err = resource.AsResourceKey(namespace, folderv0alpha1.RESOURCE) + request.Options.Key, err = resource.AsResourceKey(namespace, folderv1.RESOURCE) default: err = fmt.Errorf("bad type request") } if err != nil { - return dashboardv0alpha1.SearchResults{}, err + return dashboardv0.SearchResults{}, err } if federate != nil { @@ -2077,14 +2077,14 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex if query.Sort.Name != "" { sortName, isDesc, err := legacysearcher.ParseSortName(query.Sort.Name) if err != nil { - return dashboardv0alpha1.SearchResults{}, err + return dashboardv0.SearchResults{}, err } request.SortBy = append(request.SortBy, &resource.ResourceSearchRequest_Sort{Field: sortName, Desc: isDesc}) } res, err := dr.k8sclient.Search(ctx, query.OrgId, request) if err != nil { - return dashboardv0alpha1.SearchResults{}, err + return dashboardv0.SearchResults{}, err } return dashboardsearch.ParseResults(res, 0) @@ -2114,7 +2114,7 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex var mu sync.Mutex g, ctx := errgroup.WithContext(ctx) for _, h := range searchResults.Hits { - func(hit dashboardv0alpha1.DashboardHit) { + func(hit dashboardv0.DashboardHit) { g.Go(func() error { out, err := dr.k8sclient.Get(ctx, hit.Name, query.OrgId, v1.GetOptions{}) if err != nil { @@ -2246,7 +2246,7 @@ func (dr *DashboardServiceImpl) unstructuredToLegacyDashboardWithUsers(item *uns FolderUID: obj.GetFolder(), Version: int(dashVersion), Data: simplejson.NewFromAny(spec), - APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv1alpha1.GROUP+"/"), + APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv1.GROUP+"/"), } out.Created = obj.GetCreationTimestamp().Time @@ -2335,7 +2335,7 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names finalObj.Object["spec"] = obj finalObj.SetName(uid) finalObj.SetNamespace(namespace) - finalObj.SetGroupVersionKind(dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind()) + finalObj.SetGroupVersionKind(dashboardv1.DashboardResourceInfo.GroupVersionKind()) meta, err := utils.MetaAccessor(finalObj) if err != nil { @@ -2353,7 +2353,7 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names return finalObj, nil } -func getFolderUIDs(hits []dashboardv0alpha1.DashboardHit) []string { +func getFolderUIDs(hits []dashboardv0.DashboardHit) []string { folderSet := map[string]bool{} for _, hit := range hits { if hit.Folder != "" && !folderSet[hit.Folder] { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 42d04c82db7..313edeba510 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apiserver/pkg/endpoints/request" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" @@ -542,8 +542,8 @@ func TestGetProvisionedDashboardData(t *testing.T) { k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "name": "uid", "labels": map[string]interface{}{ @@ -648,8 +648,8 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { provisioningTimestamp := int64(1234567) k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "name": "uid", "labels": map[string]interface{}{ @@ -742,8 +742,8 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { provisioningTimestamp := int64(1234567) k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "name": "uid", "labels": map[string]interface{}{ @@ -975,8 +975,8 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "name": "uid", "labels": map[string]interface{}{ @@ -1120,7 +1120,7 @@ func TestUnprovisionDashboard(t *testing.T) { }} k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "dashboard.grafana.app/v1alpha1", + "apiVersion": dashboardv1.APIVERSION, "kind": "Dashboard", "metadata": map[string]any{ "name": "uid", @@ -2491,7 +2491,7 @@ func TestSetDefaultPermissionsAfterCreate(t *testing.T) { // Create test object key := &resource.ResourceKey{Group: "dashboard.grafana.app", Resource: "dashboards", Name: "test", Namespace: "default"} - obj := &dashboardv1alpha1.Dashboard{ + obj := &dashboardv1.Dashboard{ TypeMeta: metav1.TypeMeta{ APIVersion: "dashboard.grafana.app/v0alpha1", }, @@ -2834,8 +2834,8 @@ func TestK8sDashboardCleanupJob(t *testing.T) { func createTestUnstructuredDashboard(uid, title string, resourceVersion string) unstructured.Unstructured { return unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "name": uid, "deletionTimestamp": "2023-01-01T00:00:00Z", diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index bb314f9eea8..49be4af1883 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -11,7 +11,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" @@ -55,7 +55,7 @@ func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.Dash k8sclient: client.NewK8sHandler( dual, request.GetNamespaceMapper(cfg), - v1alpha1.DashboardResourceInfo.GroupVersionResource(), + dashv1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashboardStore, userService, diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 568a33f60ac..9e211337f3f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -564,8 +564,9 @@ var ( { Name: "kubernetesClientDashboardsFolders", Description: "Route the folder and dashboard service requests to k8s", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, Owner: grafanaAppPlatformSquad, + Expression: "true", // enabled by default }, { Name: "datasourceQueryTypes", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 432f331d2f2..7edec7f5e13 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -72,7 +72,7 @@ formatString,GA,@grafana/dataviz-squad,false,false,true kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true -kubernetesClientDashboardsFolders,experimental,@grafana/grafana-app-platform-squad,false,false,false +kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 542851adf3a..a722b4ab073 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1644,13 +1644,17 @@ { "metadata": { "name": "kubernetesClientDashboardsFolders", - "resourceVersion": "1743693517832", - "creationTimestamp": "2025-02-18T21:15:35Z" + "resourceVersion": "1744337414536", + "creationTimestamp": "2025-02-18T21:15:35Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-04-11 02:10:14.536012 +0000 UTC" + } }, "spec": { "description": "Route the folder and dashboard service requests to k8s", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad" + "stage": "GA", + "codeowner": "@grafana/grafana-app-platform-squad", + "expression": "true" } }, { diff --git a/pkg/services/folder/folderimpl/conversions_test.go b/pkg/services/folder/folderimpl/conversions_test.go index 9e18c6e0050..e777b2f8220 100644 --- a/pkg/services/folder/folderimpl/conversions_test.go +++ b/pkg/services/folder/folderimpl/conversions_test.go @@ -18,7 +18,7 @@ func TestFolderConversions(t *testing.T) { input := &unstructured.Unstructured{} err := input.UnmarshalJSON([]byte(`{ "kind": "Folder", - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "metadata": { "name": "be79sztagf20wd", "namespace": "default", @@ -86,10 +86,10 @@ func TestFolderConversions(t *testing.T) { func TestFolderListConversions(t *testing.T) { input := &unstructured.UnstructuredList{} err := input.UnmarshalJSON([]byte(`{ - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "items": [ { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": { @@ -113,7 +113,7 @@ func TestFolderListConversions(t *testing.T) { } }, { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": { @@ -135,7 +135,7 @@ func TestFolderListConversions(t *testing.T) { } }, { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": { @@ -158,7 +158,7 @@ func TestFolderListConversions(t *testing.T) { } }, { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": { @@ -180,7 +180,7 @@ func TestFolderListConversions(t *testing.T) { } }, { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": { @@ -203,7 +203,7 @@ func TestFolderListConversions(t *testing.T) { } }, { - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "annotations": {}, diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index b40b54aece5..adb66dd2820 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -18,9 +18,9 @@ import ( "github.com/grafana/dskit/concurrency" - dashboardalpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" @@ -118,7 +118,7 @@ func ProvideService( k8sHandler := client.NewK8sHandler( dual, request.GetNamespaceMapper(cfg), - v0alpha1.FolderResourceInfo.GroupVersionResource(), + folderv1.FolderResourceInfo.GroupVersionResource(), restConfig.GetRestConfig, dashboardStore, userService, @@ -136,7 +136,7 @@ func ProvideService( dashHandler := client.NewK8sHandler( dual, request.GetNamespaceMapper(cfg), - dashboardalpha1.DashboardResourceInfo.GroupVersionResource(), + dashboardv1.DashboardResourceInfo.GroupVersionResource(), restConfig.GetRestConfig, dashboardStore, userService, diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 6842724fdd3..9e67cfac626 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -16,7 +16,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/events" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/slugify" @@ -176,8 +176,8 @@ func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.S Options: &resource.ListOptions{ Key: &resource.ResourceKey{ Namespace: s.k8sclient.GetNamespace(query.OrgID), - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: folderv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource, }, Fields: []*resource.Requirement{}, Labels: []*resource.Requirement{}, @@ -252,8 +252,8 @@ func (s *Service) getFolderByIDFromApiServer(ctx context.Context, id int64, orgI folderkey := &resource.ResourceKey{ Namespace: s.k8sclient.GetNamespace(orgID), - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: folderv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource, } request := &resource.ResourceSearchRequest{ @@ -308,8 +308,8 @@ func (s *Service) getFolderByTitleFromApiServer(ctx context.Context, orgID int64 folderkey := &resource.ResourceKey{ Namespace: s.k8sclient.GetNamespace(orgID), - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: folderv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource, } request := &resource.ResourceSearchRequest{ diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index 472fdf35858..08f8854d3ca 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + foldersv1 "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log/logtest" @@ -59,9 +59,9 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { t.Skip("skipping integration test") } - m := map[string]v0alpha1.Folder{} + m := map[string]foldersv1.Folder{} - unifiedStorageFolder := &v0alpha1.Folder{} + unifiedStorageFolder := &foldersv1.Folder{} unifiedStorageFolder.Kind = "folder" fooFolder := &folder.Folder{ @@ -82,19 +82,19 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("DELETE /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/deletefolder", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("DELETE /apis/folder.grafana.app/v1/namespaces/default/folders/deletefolder", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") }) - mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("GET /apis/folder.grafana.app/v1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - l := &v0alpha1.FolderList{} + l := &foldersv1.FolderList{} l.Kind = "Folder" err := json.NewEncoder(w).Encode(l) require.NoError(t, err) }) - mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/foo", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("GET /apis/folder.grafana.app/v1/namespaces/default/folders/foo", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") namespacer := func(_ int64) string { return "1" } result, err := internalfolders.LegacyFolderToUnstructured(fooFolder, namespacer) @@ -104,7 +104,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { require.NoError(t, err) }) - mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("GET /apis/folder.grafana.app/v1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") namespacer := func(_ int64) string { return "1" } result, err := internalfolders.LegacyFolderToUnstructured(updateFolder, namespacer) @@ -114,12 +114,12 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { require.NoError(t, err) }) - mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("PUT /apis/folder.grafana.app/v1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") buf, err := io.ReadAll(req.Body) require.NoError(t, err) - var foldr v0alpha1.Folder + var foldr foldersv1.Folder err = json.Unmarshal(buf, &foldr) require.NoError(t, err) @@ -133,22 +133,22 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { require.NoError(t, err) }) - mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("GET /apis/folder.grafana.app/v1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(unifiedStorageFolder) require.NoError(t, err) }) - mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("PUT /apis/folder.grafana.app/v1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(unifiedStorageFolder) require.NoError(t, err) }) - mux.HandleFunc("POST /apis/folder.grafana.app/v0alpha1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { + mux.HandleFunc("POST /apis/folder.grafana.app/v1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") buf, err := io.ReadAll(req.Body) require.NoError(t, err) - var folder v0alpha1.Folder + var folder foldersv1.Folder err = json.Unmarshal(buf, &folder) require.NoError(t, err) @@ -184,7 +184,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { features := featuremgmt.WithFeatures(featuresArr...) dashboardStore := dashboards.NewFakeDashboardStore(t) - k8sCli := client.NewK8sHandler(dualwrite.ProvideTestService(), request.GetNamespaceMapper(cfg), v0alpha1.FolderResourceInfo.GroupVersionResource(), restCfgProvider.GetRestConfig, dashboardStore, userService, nil, sort.ProvideService()) + k8sCli := client.NewK8sHandler(dualwrite.ProvideTestService(), request.GetNamespaceMapper(cfg), foldersv1.FolderResourceInfo.GroupVersionResource(), restCfgProvider.GetRestConfig, dashboardStore, userService, nil, sort.ProvideService()) unifiedStore := ProvideUnifiedStore(k8sCli, userService) ctx := context.Background() @@ -541,8 +541,8 @@ func TestSearchFoldersFromApiServer(t *testing.T) { Options: &resource.ListOptions{ Key: &resource.ResourceKey{ Namespace: "default", - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: foldersv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: foldersv1.FolderResourceInfo.GroupVersionResource().Resource, }, Fields: []*resource.Requirement{ { @@ -631,8 +631,8 @@ func TestSearchFoldersFromApiServer(t *testing.T) { Options: &resource.ListOptions{ Key: &resource.ResourceKey{ Namespace: "default", - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: foldersv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: foldersv1.FolderResourceInfo.GroupVersionResource().Resource, }, Fields: []*resource.Requirement{}, Labels: []*resource.Requirement{ @@ -700,8 +700,8 @@ func TestSearchFoldersFromApiServer(t *testing.T) { Options: &resource.ListOptions{ Key: &resource.ResourceKey{ Namespace: "default", - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: foldersv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: foldersv1.FolderResourceInfo.GroupVersionResource().Resource, }, Fields: []*resource.Requirement{}, Labels: []*resource.Requirement{}, @@ -797,8 +797,8 @@ func TestGetFoldersFromApiServer(t *testing.T) { Options: &resource.ListOptions{ Key: &resource.ResourceKey{ Namespace: "default", - Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group, - Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource, + Group: foldersv1.FolderResourceInfo.GroupVersionResource().Group, + Resource: foldersv1.FolderResourceInfo.GroupVersionResource().Resource, }, Fields: []*resource.Requirement{}, Labels: []*resource.Requirement{}, diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 41e1981a665..665ba45dd81 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -16,7 +16,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/infra/log" internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -452,7 +452,7 @@ func (ss *FolderUnifiedStoreImpl) CountInOrg(ctx context.Context, orgID int64) ( } func toFolderLegacyCounts(u *unstructured.Unstructured) (*folder.DescendantCounts, error) { - ds, err := v0alpha1.UnstructuredToDescendantCounts(u) + ds, err := folderv1.UnstructuredToDescendantCounts(u) if err != nil { return nil, err } diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 8e5fa5db84d..6b1d0428ed9 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -61,12 +61,14 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink }) } if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) { - generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{ + provisioningNode := &navtree.NavLink{ Text: "Provisioning", Id: "provisioning", SubTitle: "View and manage your provisioning connections", Url: s.cfg.AppSubURL + "/admin/provisioning", - }) + } + + configNodes = append(configNodes, provisioningNode) } generalNode := &navtree.NavLink{ diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 1ba3422cecb..f1429bb01af 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -368,6 +368,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "folderUid": "namespaceUID", "uid": "RuleUID", "query": "vector(1)", + "queriedDatasourceUIDs": ["AUID"], "alerts": [{ "labels": { "job": "prometheus" @@ -433,6 +434,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "state": "inactive", "name": "AlwaysFiring", "query": "vector(1)", + "queriedDatasourceUIDs": ["AUID"], "folderUid": "namespaceUID", "uid": "RuleUID", "alerts": [{ @@ -499,6 +501,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "state": "inactive", "name": "AlwaysFiring", "query": "vector(1) | vector(1)", + "queriedDatasourceUIDs": ["AUID", "BUID"], "folderUid": "namespaceUID", "uid": "RuleUID", "alerts": [{ diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index d130274ced0..076fa35a171 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/folder" @@ -544,13 +545,16 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe } } + queriedDatasourceUIDs := extractDatasourceUIDs(rule) + alertingRule := apimodels.AlertingRule{ - State: "inactive", - Name: rule.Title, - Query: ruleToQuery(log, rule), - Duration: rule.For.Seconds(), - KeepFiringFor: rule.KeepFiringFor.Seconds(), - Annotations: apimodels.LabelsFromMap(rule.Annotations), + State: "inactive", + Name: rule.Title, + Query: ruleToQuery(log, rule), + QueriedDatasourceUIDs: queriedDatasourceUIDs, + Duration: rule.For.Seconds(), + KeepFiringFor: rule.KeepFiringFor.Seconds(), + Annotations: apimodels.LabelsFromMap(rule.Annotations), } newRule := apimodels.Rule{ @@ -663,6 +667,19 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe return newGroup, rulesTotals } +// extractDatasourceUIDs extracts datasource UIDs from a rule +func extractDatasourceUIDs(rule *ngmodels.AlertRule) []string { + queriedDatasourceUIDs := make([]string, 0, len(rule.Data)) + for _, query := range rule.Data { + // Skip expression datasources (UID -100 or __expr__) + if expr.IsDataSource(query.DatasourceUID) { + continue + } + queriedDatasourceUIDs = append(queriedDatasourceUIDs, query.DatasourceUID) + } + return queriedDatasourceUIDs +} + // ruleToQuery attempts to extract the datasource queries from the alert query model. // Returns the whole JSON model as a string if it fails to extract a minimum of 1 query. func ruleToQuery(logger log.Logger, rule *ngmodels.AlertRule) string { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index 72ca0ebb730..0e8c319113b 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -2,12 +2,10 @@ package definitions import ( "fmt" - tmplhtml "html/template" "regexp" "strings" - tmpltext "text/template" - "github.com/prometheus/alertmanager/template" + "github.com/grafana/alerting/templates" "gopkg.in/yaml.v3" ) @@ -35,17 +33,12 @@ func (t *NotificationTemplate) Validate() error { t.Template = content // Validate template contents. We try to stick as close to what will actually happen when the templates are parsed - // by the alertmanager as possible. That means parsing with both the text and html parsers and making sure we set - // the template name and options. - ttext := tmpltext.New(t.Name).Option("missingkey=zero") - ttext.Funcs(tmpltext.FuncMap(template.DefaultFuncs)) - if _, err := ttext.Parse(t.Template); err != nil { - return fmt.Errorf("invalid template: %w", err) + // by the alertmanager as possible. + tmpl, err := templates.NewTemplate() + if err != nil { + return fmt.Errorf("failed to create template: %w", err) } - - thtml := tmplhtml.New(t.Name).Option("missingkey=zero") - thtml.Funcs(tmplhtml.FuncMap(template.DefaultFuncs)) - if _, err := thtml.Parse(t.Template); err != nil { + if err := tmpl.Parse(strings.NewReader(t.Template)); err != nil { return fmt.Errorf("invalid template: %w", err) } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go index 4fbbd983b13..74f186003d7 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go @@ -442,7 +442,6 @@ func TestValidateNotificationTemplates(t *testing.T) { expError: errors.New("invalid template: template: Different name than definition:1: template: multiple definition of template \"Alert Instance Template\""), }, { - // This is fine as long as the template name is different from the definition, it just ignores the extra text. name: "Extra text outside definition block - different template name and definition", template: NotificationTemplate{ Name: "Different name than definition", @@ -452,16 +451,16 @@ func TestValidateNotificationTemplates(t *testing.T) { expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`, expError: nil, }, + // This test used to error because our template code parsed the template with the filename as template name. + // However, we have since moved away from this. We keep this test to ensure we don't regress. { - // This is NOT fine as the template name is the same as the definition. - // GO template parser will treat it as if it's wrapped in {{ define "Alert Instance Template" }}, thus creating a duplicate definition. name: "Extra text outside definition block - same template name and definition", template: NotificationTemplate{ Name: "Alert Instance Template", Template: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`, Provenance: "test", }, - expError: errors.New("invalid template: template: Alert Instance Template:1: template: multiple definition of template \"Alert Instance Template\""), + expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`, }, } diff --git a/pkg/services/ngalert/api/tooling/definitions/contact_points.go b/pkg/services/ngalert/api/tooling/definitions/contact_points.go index 597694e6d60..9acbe144e88 100644 --- a/pkg/services/ngalert/api/tooling/definitions/contact_points.go +++ b/pkg/services/ngalert/api/tooling/definitions/contact_points.go @@ -313,16 +313,24 @@ type WebhookIntegration struct { URL string `json:"url" yaml:"url" hcl:"url"` - HTTPMethod *string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty" hcl:"http_method"` - MaxAlerts *int64 `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty" hcl:"max_alerts"` - AuthorizationScheme *string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty" hcl:"authorization_scheme"` - AuthorizationCredentials *Secret `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty" hcl:"authorization_credentials"` - User *string `json:"username,omitempty" yaml:"username,omitempty" hcl:"basic_auth_user"` - Password *Secret `json:"password,omitempty" yaml:"password,omitempty" hcl:"basic_auth_password"` - Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"` - Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` - TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"` - HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"` + HTTPMethod *string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty" hcl:"http_method"` + MaxAlerts *int64 `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty" hcl:"max_alerts"` + AuthorizationScheme *string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty" hcl:"authorization_scheme"` + AuthorizationCredentials *Secret `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty" hcl:"authorization_credentials"` + User *string `json:"username,omitempty" yaml:"username,omitempty" hcl:"basic_auth_user"` + Password *Secret `json:"password,omitempty" yaml:"password,omitempty" hcl:"basic_auth_password"` + ExtraHeaders *map[string]string `json:"headers,omitempty" yaml:"headers,omitempty" hcl:"headers"` + Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"` + Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` + TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"` + HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"` + + Payload *CustomPayload `json:"payload,omitempty" yaml:"payload,omitempty" hcl:"payload,block"` +} + +type CustomPayload struct { + Template *string `json:"template,omitempty" yaml:"template,omitempty" hcl:"template"` + Vars *map[string]string `json:"vars,omitempty" yaml:"vars,omitempty" hcl:"vars"` } type HMACConfig struct { diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 108d5e3cf0b..20130c9f331 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -152,9 +152,10 @@ type AlertingRule struct { // required: true Name string `json:"name,omitempty"` // required: true - Query string `json:"query,omitempty"` - Duration float64 `json:"duration,omitempty"` - KeepFiringFor float64 `json:"keepFiringFor,omitempty"` + Query string `json:"query,omitempty"` + QueriedDatasourceUIDs []string `json:"queriedDatasourceUIDs,omitempty"` + Duration float64 `json:"duration,omitempty"` + KeepFiringFor float64 `json:"keepFiringFor,omitempty"` // required: true Annotations promlabels.Labels `json:"annotations,omitempty"` // required: true @@ -168,12 +169,10 @@ type AlertingRule struct { // adapted from cortex // swagger:model type Rule struct { + UID string `json:"uid,omitempty"` // required: true - UID string `json:"uid"` - // required: true - Name string `json:"name"` - // required: true - FolderUID string `json:"folderUid"` + Name string `json:"name"` + FolderUID string `json:"folderUid,omitempty"` // required: true Query string `json:"query"` Labels promlabels.Labels `json:"labels,omitempty"` diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 6d3429a7359..5b70d7f58fa 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -952,6 +952,13 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "authorization_credentials", Secure: true, }, + { // New in 12.0. + Label: "Extra Headers", + Description: "Optionally provide extra headers to be used in the request.", + Element: ElementTypeKeyValueMap, + InputType: InputTypeText, + PropertyName: "headers", + }, { // New in 8.0. TODO: How to enforce only numbers? Label: "Max Alerts", Description: "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", @@ -974,6 +981,30 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "message", Placeholder: alertingTemplates.DefaultMessageEmbed, }, + { // New in 12.0. + Label: "Custom Payload", + Description: "Optionally provide a templated payload. Overrides 'Message' and 'Title' field.", + Element: ElementTypeSubform, + PropertyName: "payload", + SubformOptions: []NotifierOption{ + { + Label: "Payload Template", + Description: "Custom payload template.", + Element: ElementTypeTextArea, + PropertyName: "template", + Placeholder: `{{ template "webhook.default.payload" . }}`, + Required: true, + }, + { + Label: "Payload Variables", + Description: "Optionally provide a variables to be used in the payload template. They will be available in the template as `.Vars.`.", + Element: ElementTypeKeyValueMap, + InputType: InputTypeText, + PropertyName: "vars", + }, + }, + }, + { Label: "TLS", PropertyName: "tlsConfig", diff --git a/pkg/services/ngalert/notifier/templates.go b/pkg/services/ngalert/notifier/templates.go index 9f9a49f722c..5611bbe2b4c 100644 --- a/pkg/services/ngalert/notifier/templates.go +++ b/pkg/services/ngalert/notifier/templates.go @@ -15,8 +15,8 @@ type TestTemplatesResults = alertingNotify.TestTemplatesResults var ( DefaultLabels = map[string]string{ - prometheusModel.AlertNameLabel: `alert title`, - alertingModels.FolderTitleLabel: `folder title`, + prometheusModel.AlertNameLabel: `TestAlert`, + alertingModels.FolderTitleLabel: `Test Folder`, } DefaultAnnotations = map[string]string{ alertingModels.ValuesAnnotation: `{"B":22,"C":1}`, diff --git a/pkg/services/ngalert/notifier/templates_test.go b/pkg/services/ngalert/notifier/templates_test.go index 902c534a058..1b1da5a2890 100644 --- a/pkg/services/ngalert/notifier/templates_test.go +++ b/pkg/services/ngalert/notifier/templates_test.go @@ -84,7 +84,7 @@ CommonAnnotations: {{ range .CommonAnnotations.SortedPairs }}{{ .Name }}={{ .Val expected: TestTemplatesResults{ Results: []alertingNotify.TestTemplatesResult{{ Name: "slack.title", - Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=folder title lbl1=val1 \nCommonAnnotations: ann1=annv1 \n", + Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=Test Folder lbl1=val1 \nCommonAnnotations: ann1=annv1 \n", Scope: alertingNotify.TemplateScope(apimodels.RootScope), }}, Errors: nil, diff --git a/pkg/services/ngalert/notifier/testreceivers.go b/pkg/services/ngalert/notifier/testreceivers.go index 5844ff3aa30..cbd1ca29f87 100644 --- a/pkg/services/ngalert/notifier/testreceivers.go +++ b/pkg/services/ngalert/notifier/testreceivers.go @@ -5,6 +5,7 @@ import ( "encoding/json" alertingNotify "github.com/grafana/alerting/notify" + v2 "github.com/prometheus/alertmanager/api/v2" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -30,13 +31,17 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei }, }) } - var alert *alertingNotify.TestReceiversConfigAlertParams + a := &alertingNotify.PostableAlert{} if c.Alert != nil { - alert = &alertingNotify.TestReceiversConfigAlertParams{Annotations: c.Alert.Annotations, Labels: c.Alert.Labels} + a.Annotations = v2.ModelLabelSetToAPILabelSet(c.Alert.Annotations) + a.Labels = v2.ModelLabelSetToAPILabelSet(c.Alert.Labels) } - + AddDefaultLabelsAndAnnotations(a) return am.Base.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{ - Alert: alert, + Alert: &alertingNotify.TestReceiversConfigAlertParams{ + Annotations: v2.APILabelSetToModelLabelSet(a.Annotations), + Labels: v2.APILabelSetToModelLabelSet(a.Labels), + }, Receivers: receivers, }) } diff --git a/pkg/services/pluginsintegration/pluginconfig/request_test.go b/pkg/services/pluginsintegration/pluginconfig/request_test.go index f8aa0cbc9f2..8153c24f10a 100644 --- a/pkg/services/pluginsintegration/pluginconfig/request_test.go +++ b/pkg/services/pluginsintegration/pluginconfig/request_test.go @@ -311,7 +311,7 @@ func TestRequestConfigProvider_PluginRequestConfig_azure(t *testing.T) { UsernameAssertion: true, }, UserIdentityFallbackCredentialsEnabled: true, - ForwardSettingsPlugins: []string{"grafana-azure-monitor-datasource", "prometheus", "grafana-azure-data-explorer-datasource", "mssql"}, + ForwardSettingsPlugins: []string{"grafana-azure-monitor-datasource", "prometheus", "grafana-azure-data-explorer-datasource", "mssql", "grafana-azureprometheus-datasource"}, AzureEntraPasswordCredentialsEnabled: true, } diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go index afc40f9e37f..85a859935ed 100644 --- a/pkg/services/preference/prefimpl/pref_test.go +++ b/pkg/services/preference/prefimpl/pref_test.go @@ -92,7 +92,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { WeekStart: &weekStartOne, JSONData: &pref.PreferenceJSONData{ Language: "en-GB", - Locale: "en-US", + Locale: "en", }, }, pref.Preference{ @@ -104,7 +104,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { WeekStart: &weekStartTwo, JSONData: &pref.PreferenceJSONData{ Language: "en-AU", - Locale: "es-ES", + Locale: "es", }, }, ) @@ -120,7 +120,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { HomeDashboardID: 4, JSONData: &pref.PreferenceJSONData{ Language: "en-AU", - Locale: "es-ES", + Locale: "es", }, } if diff := cmp.Diff(expected, preference); diff != "" { @@ -140,7 +140,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { HomeDashboardID: 1, JSONData: &pref.PreferenceJSONData{ Language: "en-GB", - Locale: "en-US", + Locale: "en", }, } if diff := cmp.Diff(expected, preference); diff != "" { @@ -162,7 +162,7 @@ func TestGetDefaults_JSONData(t *testing.T) { Language: "en-GB", } orgPreferencesWithLocaleJsonData := pref.PreferenceJSONData{ - Locale: "en-US", + Locale: "en", } team2PreferencesJsonData := pref.PreferenceJSONData{} team1PreferencesJsonData := pref.PreferenceJSONData{} @@ -248,7 +248,7 @@ func TestGetDefaults_JSONData(t *testing.T) { require.Equal(t, &pref.Preference{ WeekStart: &weekStart, JSONData: &pref.PreferenceJSONData{ - Locale: "en-US", + Locale: "en", QueryHistory: queryPreference, }, }, preference) diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index ce11d7f3778..10ef574abc7 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -242,7 +242,7 @@ func (d *PublicDashboardStoreImpl) Update(ctx context.Context, cmd SavePublicDas cmd.PublicDashboard.Share, string(timeSettingsJSON), cmd.PublicDashboard.UpdatedBy, - cmd.PublicDashboard.UpdatedAt.UTC().Format("2006-01-02 15:04:05"), + cmd.PublicDashboard.UpdatedAt.UTC(), cmd.PublicDashboard.Uid) if err != nil { diff --git a/pkg/services/rendering/capabilities_test.go b/pkg/services/rendering/capabilities_test.go index e9b4989dbbf..956fae30e0f 100644 --- a/pkg/services/rendering/capabilities_test.go +++ b/pkg/services/rendering/capabilities_test.go @@ -121,7 +121,7 @@ func TestCapabilities(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rs.Cfg.RendererUrl = tt.rendererUrl + rs.Cfg.RendererServerUrl = tt.rendererUrl rs.version = tt.rendererVersion res, err := rs.HasCapability(context.Background(), tt.capabilityName) diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 0f877867465..705f9672240 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -71,7 +71,7 @@ func (rs *RenderingService) renderCSVViaHTTP(ctx context.Context, renderKey stri } func (rs *RenderingService) generateImageRendererURL(renderType RenderType, opts Opts, renderKey string) (*url.URL, error) { - rendererUrl := rs.Cfg.RendererUrl + rendererUrl := rs.Cfg.RendererServerUrl if renderType == RenderCSV { rendererUrl += "/csv" } @@ -242,7 +242,7 @@ func (rs *RenderingService) getRemotePluginVersionWithRetry(callback func(string } func (rs *RenderingService) getRemotePluginVersion() (string, error) { - rendererURL, err := url.Parse(rs.Cfg.RendererUrl + "/version") + rendererURL, err := url.Parse(rs.Cfg.RendererServerUrl + "/version") if err != nil { return "", err } diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 8f25cb8da22..2585f9b783e 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -27,18 +27,19 @@ import ( var _ Service = (*RenderingService)(nil) type RenderingService struct { - log log.Logger - plugin Plugin - renderAction renderFunc - renderCSVAction renderCSVFunc - sanitizeSVGAction sanitizeFunc - sanitizeURL string - domain string - inProgressCount int32 - version string - versionMutex sync.RWMutex - capabilities []Capability - pluginAvailable bool + log log.Logger + plugin Plugin + renderAction renderFunc + renderCSVAction renderCSVFunc + sanitizeSVGAction sanitizeFunc + sanitizeURL string + domain string + inProgressCount int32 + version string + versionMutex sync.RWMutex + capabilities []Capability + pluginAvailable bool + rendererCallbackURL string perRequestRenderKeyProvider renderKeyProvider Cfg *setting.Cfg @@ -80,9 +81,24 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, remot // value used for domain attribute of renderKey cookie var domain string + // value used by the image renderer to make requests to Grafana + rendererCallbackURL := cfg.RendererCallbackUrl + if cfg.RendererServerUrl != "" { + sanitizeURL = getSanitizerURL(cfg.RendererServerUrl) + + // Default value for callback URL using a remote renderer should be AppURL + if rendererCallbackURL == "" { + rendererCallbackURL = cfg.AppURL + } + } + switch { - case cfg.RendererCallbackUrl != "": - u, err := url.Parse(cfg.RendererCallbackUrl) + case rendererCallbackURL != "": + if rendererCallbackURL[len(rendererCallbackURL)-1] != '/' { + rendererCallbackURL += "/" + } + + u, err := url.Parse(rendererCallbackURL) if err != nil { logger.Warn("Image renderer callback url is not valid. " + "Please provide a valid RendererCallbackUrl. " + @@ -96,10 +112,6 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, remot domain = "localhost" } - if cfg.RendererUrl != "" { - sanitizeURL = getSanitizerURL(cfg.RendererUrl) - } - var renderKeyProvider renderKeyProvider if features.IsEnabledGlobally(featuremgmt.FlagRenderAuthJWT) { renderKeyProvider = &jwtRenderKeyProvider{ @@ -145,6 +157,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, remot domain: domain, sanitizeURL: sanitizeURL, pluginAvailable: exists, + rendererCallbackURL: rendererCallbackURL, } gob.Register(&RenderUser{}) @@ -215,7 +228,7 @@ func (rs *RenderingService) Run(ctx context.Context) error { } func (rs *RenderingService) remoteAvailable() bool { - return rs.Cfg.RendererUrl != "" + return rs.Cfg.RendererServerUrl != "" } func (rs *RenderingService) IsAvailable(ctx context.Context) bool { @@ -424,13 +437,14 @@ func (rs *RenderingService) getNewFilePath(rt RenderType) (string, error) { // getGrafanaCallbackURL creates a URL to send to the image rendering as callback for rendering a Grafana resource func (rs *RenderingService) getGrafanaCallbackURL(path string) string { - if rs.Cfg.RendererUrl != "" || rs.Cfg.RendererCallbackUrl != "" { - // The backend rendering service can potentially be remote. - // So we need to use the root_url to ensure the rendering service - // can reach this Grafana instance. + if rs.rendererCallbackURL != "" { + // rendererCallbackURL should be set if: + // - the backend rendering service is remote (default value is cfg.AppURL + // and set when initializing the service) + // - the service is a plugin and Grafana is running behind a proxy changing its domain // &render=1 signals to the legacy redirect layer to - return fmt.Sprintf("%s%s&render=1", rs.Cfg.RendererCallbackUrl, path) + return fmt.Sprintf("%s%s&render=1", rs.rendererCallbackURL, path) } protocol := rs.Cfg.Protocol diff --git a/pkg/services/rendering/rendering_test.go b/pkg/services/rendering/rendering_test.go index 2bcd6463633..15f473b555c 100644 --- a/pkg/services/rendering/rendering_test.go +++ b/pkg/services/rendering/rendering_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -25,21 +26,21 @@ func TestGetUrl(t *testing.T) { } t.Run("When renderer and callback url configured should return callback url plus path", func(t *testing.T) { - rs.Cfg.RendererUrl = "http://localhost:8081/render" - rs.Cfg.RendererCallbackUrl = "http://public-grafana.com/" + rs.Cfg.RendererServerUrl = "http://localhost:8081/render" + rs.rendererCallbackURL = "http://public-grafana.com/" url := rs.getGrafanaCallbackURL(path) - require.Equal(t, rs.Cfg.RendererCallbackUrl+path+"&render=1", url) + require.Equal(t, rs.rendererCallbackURL+path+"&render=1", url) }) t.Run("When callback url is configured and https should return domain of callback url plus path", func(t *testing.T) { - rs.Cfg.RendererCallbackUrl = "https://public-grafana.com/" + rs.rendererCallbackURL = "https://public-grafana.com/" url := rs.getGrafanaCallbackURL(path) - require.Equal(t, rs.Cfg.RendererCallbackUrl+path+"&render=1", url) + require.Equal(t, rs.rendererCallbackURL+path+"&render=1", url) }) t.Run("When renderer url not configured", func(t *testing.T) { - rs.Cfg.RendererUrl = "" - rs.Cfg.RendererCallbackUrl = "" + rs.Cfg.RendererServerUrl = "" + rs.rendererCallbackURL = "" rs.domain = "localhost" rs.Cfg.HTTPPort = "3000" @@ -128,8 +129,8 @@ func TestRenderLimitImage(t *testing.T) { rs := RenderingService{ Cfg: &setting.Cfg{ - HomePath: path, - RendererUrl: "http://localhost:8081/render", + HomePath: path, + RendererServerUrl: "http://localhost:8081/render", }, inProgressCount: 2, log: log.New("test"), @@ -170,7 +171,7 @@ func TestRenderLimitImage(t *testing.T) { func TestRenderLimitImageError(t *testing.T) { rs := RenderingService{ Cfg: &setting.Cfg{ - RendererUrl: "http://localhost:8081/render", + RendererServerUrl: "http://localhost:8081/render", }, inProgressCount: 2, log: log.New("test"), @@ -201,7 +202,7 @@ func TestRenderingServiceGetRemotePluginVersion(t *testing.T) { })) defer server.Close() - rs.Cfg.RendererUrl = server.URL + "/render" + rs.Cfg.RendererServerUrl = server.URL + "/render" version, err := rs.getRemotePluginVersion() require.NoError(t, err) @@ -214,7 +215,7 @@ func TestRenderingServiceGetRemotePluginVersion(t *testing.T) { })) defer server.Close() - rs.Cfg.RendererUrl = server.URL + "/render" + rs.Cfg.RendererServerUrl = server.URL + "/render" version, err := rs.getRemotePluginVersion() require.NoError(t, err) @@ -239,7 +240,7 @@ func TestRenderingServiceGetRemotePluginVersion(t *testing.T) { })) defer server.Close() - rs.Cfg.RendererUrl = server.URL + "/render" + rs.Cfg.RendererServerUrl = server.URL + "/render" remoteVersionFetchInterval = time.Millisecond remoteVersionFetchRetries = 5 go func() { @@ -249,3 +250,76 @@ func TestRenderingServiceGetRemotePluginVersion(t *testing.T) { require.Eventually(t, func() bool { return rs.Version() == "3.1.4159" }, time.Second, time.Millisecond) }) } + +func TestProvideService(t *testing.T) { + cfg := setting.NewCfg() + cfg.AppURL = "http://app-url" + cfg.ImagesDir = filepath.Join(t.TempDir(), "images") + cfg.CSVsDir = filepath.Join(t.TempDir(), "csvs") + cfg.PDFsDir = filepath.Join(t.TempDir(), "pdfs") + + t.Run("Default configuration values", func(t *testing.T) { + rs, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.NoError(t, err) + + require.Equal(t, "", rs.Cfg.RendererServerUrl) + require.Equal(t, "", rs.rendererCallbackURL) + require.Equal(t, "", rs.domain) + }) + + t.Run("RendererURL is set but not RendererCallbackUrl", func(t *testing.T) { + cfg.RendererServerUrl = "http://custom-renderer:8081" + cfg.RendererCallbackUrl = "" + + rs, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.NoError(t, err) + + require.Equal(t, "http://custom-renderer:8081", rs.Cfg.RendererServerUrl) + require.Equal(t, "http://app-url/", rs.rendererCallbackURL) + require.Equal(t, "app-url", rs.domain) + }) + + t.Run("RendererURL and RendererCallbackUrl are set", func(t *testing.T) { + cfg.RendererServerUrl = "http://custom-renderer:8081" + cfg.RendererCallbackUrl = "http://public-grafana.com/" + + rs, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.NoError(t, err) + + require.Equal(t, "http://custom-renderer:8081", rs.Cfg.RendererServerUrl) + require.Equal(t, "http://public-grafana.com/", rs.rendererCallbackURL) + require.Equal(t, "public-grafana.com", rs.domain) + }) + + t.Run("RendererURL is not set but RendererCallbackUrl is set", func(t *testing.T) { + cfg.RendererServerUrl = "" + cfg.RendererCallbackUrl = "https://public-grafana.com/" + + rs, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.NoError(t, err) + + require.Equal(t, "", rs.Cfg.RendererServerUrl) + require.Equal(t, "https://public-grafana.com/", rs.rendererCallbackURL) + require.Equal(t, "public-grafana.com", rs.domain) + }) + + t.Run("RendererCallbackURL is missing trailing slash", func(t *testing.T) { + cfg.RendererServerUrl = "" + cfg.RendererCallbackUrl = "https://public-grafana.com" + + rs, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.NoError(t, err) + + require.Equal(t, "", rs.Cfg.RendererServerUrl) + require.Equal(t, "https://public-grafana.com/", rs.rendererCallbackURL) + require.Equal(t, "public-grafana.com", rs.domain) + }) + + t.Run("RendererCallbackURL is invalid", func(t *testing.T) { + cfg.RendererServerUrl = "" + cfg.RendererCallbackUrl = "http://public{grafana" + + _, err := ProvideService(cfg, featuremgmt.WithFeatures(), nil, &dummyPluginManager{}) + require.Error(t, err) + }) +} diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index c5a0128450d..f68a1b6672d 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -27,7 +27,10 @@ type Dialect interface { ShowCreateNull() bool SQLType(col *Column) string SupportEngine() bool + // Deprecated. This doesn't work correctly for all databases. LikeStr() string + // LikeOperator returns SQL snippet and query parameter for case-insensitive LIKE operation, with optional wildcards (%) before/after the pattern. + LikeOperator(column string, wildcardBefore bool, pattern string, wildcardAfter bool) (string, string) Default(col *Column) string // BooleanValue can be used as an argument in SELECT or INSERT statements. For constructing // raw SQL queries, please use BooleanStr instead. @@ -153,6 +156,17 @@ func (b *BaseDialect) LikeStr() string { return "LIKE" } +func (b *BaseDialect) LikeOperator(column string, wildcardBefore bool, pattern string, wildcardAfter bool) (string, string) { + param := pattern + if wildcardBefore { + param = "%" + param + } + if wildcardAfter { + param = param + "%" + } + return fmt.Sprintf("%s LIKE ?", column), param +} + func (b *BaseDialect) OrStr() string { return "OR" } diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index 9d343cc2f8f..47a0f2707b5 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -35,6 +35,17 @@ func (db *PostgresDialect) LikeStr() string { return "ILIKE" } +func (db *PostgresDialect) LikeOperator(column string, wildcardBefore bool, pattern string, wildcardAfter bool) (string, string) { + param := pattern + if wildcardBefore { + param = "%" + param + } + if wildcardAfter { + param = param + "%" + } + return fmt.Sprintf("%s ILIKE ?", column), param +} + func (db *PostgresDialect) AutoIncrStr() string { return "" } diff --git a/pkg/services/sqlstore/migrator/spanner_dialect.go b/pkg/services/sqlstore/migrator/spanner_dialect.go index b4c83bdc07a..db1a1b4bc9b 100644 --- a/pkg/services/sqlstore/migrator/spanner_dialect.go +++ b/pkg/services/sqlstore/migrator/spanner_dialect.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "cloud.google.com/go/spanner" @@ -44,6 +45,18 @@ func NewSpannerDialect() Dialect { func (s *SpannerDialect) AutoIncrStr() string { return s.d.AutoIncrStr() } func (s *SpannerDialect) Quote(name string) string { return s.d.Quote(name) } func (s *SpannerDialect) SupportEngine() bool { return s.d.SupportEngine() } + +func (s *SpannerDialect) LikeOperator(column string, wildcardBefore bool, pattern string, wildcardAfter bool) (string, string) { + param := strings.ToLower(pattern) + if wildcardBefore { + param = "%" + param + } + if wildcardAfter { + param = param + "%" + } + return fmt.Sprintf("LOWER(%s) LIKE ?", column), param +} + func (s *SpannerDialect) IndexCheckSQL(tableName, indexName string) (string, []any) { return s.d.IndexCheckSql(tableName, indexName) } diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a5216b04e7a..e8d65d5f820 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -2,15 +2,11 @@ package userimpl import ( "context" - "errors" "fmt" "strconv" "strings" "time" - "github.com/go-sql-driver/mysql" - "github.com/mattn/go-sqlite3" - "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" @@ -76,8 +72,9 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { }) return nil }) + if err != nil { - return 0, handleSQLError(err) + return 0, handleSQLError(ss.dialect, err) } return cmd.ID, nil @@ -482,8 +479,6 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* Users: make([]*user.UserSearchHitDTO, 0), } err := ss.db.WithDbSession(ctx, func(dbSess *db.Session) error { - queryWithWildcards := "%" + query.Query + "%" - whereConditions := make([]string, 0) whereParams := make([]any, 0) sess := dbSess.Table("user").Alias("u") @@ -512,8 +507,11 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* whereParams = append(whereParams, acFilter.Args...) if query.Query != "" { - whereConditions = append(whereConditions, "(email "+ss.dialect.LikeStr()+" ? OR name "+ss.dialect.LikeStr()+" ? OR login "+ss.dialect.LikeStr()+" ?)") - whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards) + emailSql, emailArg := ss.dialect.LikeOperator("email", true, query.Query, true) + nameSql, nameArg := ss.dialect.LikeOperator("name", true, query.Query, true) + loginSql, loginArg := ss.dialect.LikeOperator("login", true, query.Query, true) + whereConditions = append(whereConditions, fmt.Sprintf("(%s OR %s OR %s)", emailSql, nameSql, loginSql)) + whereParams = append(whereParams, emailArg, nameArg, loginArg) } if query.IsDisabled != nil { @@ -606,29 +604,9 @@ func setOptional[T any](v *T, add func(v T)) { } } -func handleSQLError(err error) error { - if isUniqueConstraintError(err) { +func handleSQLError(dialect migrator.Dialect, err error) error { + if dialect.IsUniqueConstraintViolation(err) { return user.ErrUserAlreadyExists } return err } - -func isUniqueConstraintError(err error) bool { - // check mysql error code - var me *mysql.MySQLError - if errors.As(err, &me) && me.Number == 1062 { - return true - } - - // for postgres we check the error message - if strings.Contains(err.Error(), "duplicate key value") { - return true - } - - var se sqlite3.Error - if errors.As(err, &se) && se.ExtendedCode == sqlite3.ErrConstraintUnique { - return true - } - - return false -} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 84df40fd67a..4a77bfdbf04 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -144,7 +144,7 @@ type Cfg struct { ImagesDir string CSVsDir string PDFsDir string - RendererUrl string + RendererServerUrl string RendererCallbackUrl string RendererAuthToken string RendererConcurrentRequestLimit int @@ -226,6 +226,7 @@ type Cfg struct { MinRefreshInterval string DefaultHomeDashboardPath string DashboardPerformanceMetrics []string + PanelSeriesLimit int // Auth LoginCookieName string @@ -1149,6 +1150,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.MinRefreshInterval = valueAsString(dashboards, "min_refresh_interval", "5s") cfg.DefaultHomeDashboardPath = dashboards.Key("default_home_dashboard_path").MustString("") cfg.DashboardPerformanceMetrics = util.SplitString(dashboards.Key("dashboard_performance_metrics").MustString("")) + cfg.PanelSeriesLimit = dashboards.Key("panel_series_limit").MustInt(0) if err := readUserSettings(iniFile, cfg); err != nil { return err @@ -1166,9 +1168,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.readZanzanaSettings() - if err := cfg.readRenderingSettings(iniFile); err != nil { - return err - } + cfg.readRenderingSettings(iniFile) cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) @@ -1794,26 +1794,12 @@ func readServiceAccountSettings(iniFile *ini.File, cfg *Cfg) error { return nil } -func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) error { +func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) { renderSec := iniFile.Section("rendering") - cfg.RendererUrl = valueAsString(renderSec, "server_url", "") + cfg.RendererServerUrl = valueAsString(renderSec, "server_url", "") cfg.RendererCallbackUrl = valueAsString(renderSec, "callback_url", "") cfg.RendererAuthToken = valueAsString(renderSec, "renderer_token", "-") - if cfg.RendererCallbackUrl == "" { - cfg.RendererCallbackUrl = AppUrl - } else { - if cfg.RendererCallbackUrl[len(cfg.RendererCallbackUrl)-1] != '/' { - cfg.RendererCallbackUrl += "/" - } - _, err := url.Parse(cfg.RendererCallbackUrl) - if err != nil { - // XXX: Should return an error? - cfg.Logger.Error("Invalid callback_url.", "url", cfg.RendererCallbackUrl, "error", err) - os.Exit(1) - } - } - cfg.RendererConcurrentRequestLimit = renderSec.Key("concurrent_render_request_limit").MustInt(30) cfg.RendererRenderKeyLifeTime = renderSec.Key("render_key_lifetime").MustDuration(5 * time.Minute) cfg.RendererDefaultImageWidth = renderSec.Key("default_image_width").MustInt(1000) @@ -1822,8 +1808,6 @@ func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) error { cfg.ImagesDir = filepath.Join(cfg.DataPath, "png") cfg.CSVsDir = filepath.Join(cfg.DataPath, "csv") cfg.PDFsDir = filepath.Join(cfg.DataPath, "pdf") - - return nil } func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error { diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index b112bd3130a..0077bf27ef4 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -33,7 +33,7 @@ func TestLoadingSettings(t *testing.T) { require.Nil(t, err) require.Equal(t, "admin", cfg.AdminUser) - require.Equal(t, "http://localhost:3000/", cfg.RendererCallbackUrl) + require.Equal(t, "", cfg.RendererCallbackUrl) require.Equal(t, "TLS1.2", cfg.MinTLSVersion) }) @@ -255,17 +255,6 @@ func TestLoadingSettings(t *testing.T) { require.Equal(t, hostname, cfg.InstanceName) }) - t.Run("Reading callback_url should add trailing slash", func(t *testing.T) { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:rendering.callback_url=http://myserver/renderer"}, - }) - require.Nil(t, err) - - require.Equal(t, "http://myserver/renderer/", cfg.RendererCallbackUrl) - }) - t.Run("Only sync_ttl should return the value sync_ttl", func(t *testing.T) { cfg := NewCfg() err := cfg.Load(CommandLineArgs{ diff --git a/pkg/storage/legacysql/dualwrite/utils.go b/pkg/storage/legacysql/dualwrite/utils.go index 0d91860070b..db748679678 100644 --- a/pkg/storage/legacysql/dualwrite/utils.go +++ b/pkg/storage/legacysql/dualwrite/utils.go @@ -5,7 +5,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" ) func IsReadingLegacyDashboardsAndFolders(ctx context.Context, svc Service) bool { diff --git a/pkg/storage/legacysql/dualwrite/utils_test.go b/pkg/storage/legacysql/dualwrite/utils_test.go index f13851da484..35440a738e2 100644 --- a/pkg/storage/legacysql/dualwrite/utils_test.go +++ b/pkg/storage/legacysql/dualwrite/utils_test.go @@ -9,7 +9,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" ) func TestIsReadingLegacyDashboardsAndFolders(t *testing.T) { diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index 8efde1c2c91..f2fcb6e8e7c 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -191,7 +191,7 @@ No resources found in default namespace. To create a folder, create a file `folder-generate.yaml`: ```yaml -apiVersion: folder.grafana.app/v0alpha1 +apiVersion: folder.grafana.app/v1 kind: Folder metadata: generateName: x # anything is ok here... except yes or true -- they become boolean! diff --git a/pkg/storage/unified/apistore/fake_large.go b/pkg/storage/unified/apistore/fake_large.go index 2fb87f5af24..f222e8470d2 100644 --- a/pkg/storage/unified/apistore/fake_large.go +++ b/pkg/storage/unified/apistore/fake_large.go @@ -3,7 +3,7 @@ package apistore import ( "context" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" "k8s.io/apimachinery/pkg/runtime/schema" @@ -16,7 +16,7 @@ type LargeObjectSupportFake struct { } func (s *LargeObjectSupportFake) GroupResource() schema.GroupResource { - return dashboardv1alpha1.DashboardResourceInfo.GroupResource() + return dashboardv1.DashboardResourceInfo.GroupResource() } func (s *LargeObjectSupportFake) Threshold() int { diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index 314af27cfab..9c644bf99cd 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -202,7 +202,7 @@ require ( github.com/googleapis/go-sql-spanner v1.11.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692 // indirect + github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430 // indirect github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect @@ -212,7 +212,7 @@ require ( github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 // indirect github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // indirect - github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 // indirect + github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991 // indirect github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index 0285eb79361..a6bc7f5bc4b 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -1249,8 +1249,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692 h1:5mOChD6fbkxeafK1iuy/iO/qKLyHeougMTtRJSgvAUM= -github.com/grafana/alerting v0.0.0-20250408102153-2412c378a692/go.mod h1:3ER/8BhIEhvrddcztLQSc5ez1f1jNHIPdquc1F+DzOw= +github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430 h1:qT0D7AIV0GRu8JUrSJYuyzj86kqLgksKQjwD++DqyOM= +github.com/grafana/alerting v0.0.0-20250411135245-cad0d384d430/go.mod h1:3ER/8BhIEhvrddcztLQSc5ez1f1jNHIPdquc1F+DzOw= github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d h1:TDVZemfYeJHPyXeYCnqL7BQqsa+mpaZYth/Qm3TKaT8= github.com/grafana/authlib v0.0.0-20250325095148-d6da9c164a7d/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us= github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM= @@ -1275,8 +1275,8 @@ github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043 h1: github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043/go.mod h1:jwYig4wlnLLq4HQKDpS95nDeZi4+DmcD17KYYS1gMJg= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko= -github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 h1:kJWonBYechx35NbOUVf1ulufKyjH1UlDJDXlk8bdGn0= -github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9/go.mod h1:QdqsZpdCtg+3TEzXX3mUakjq79LFblZ8xliHmqZj3oA= +github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991 h1:3gcAHB3K1tSJ3jPY7EYm4G/TBimwcETyZVFQDsx18iA= +github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991/go.mod h1:Lmze5IWgV2qTGY0l1JQGRXpWoNGfeGX/HXdc8ByMfE4= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 h1:wSqgLKFwI7fyeqf3djRXGClBLb/UPjZ4XPm/UsKFDB0= diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index cea6060fe57..09cf01972ae 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apiserver/pkg/storage" authtypes "github.com/grafana/authlib/types" - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" ) @@ -25,11 +25,11 @@ var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) func TestPrepareObjectForStorage(t *testing.T) { - _ = v1alpha1.AddToScheme(scheme) + _ = dashv1.AddToScheme(scheme) node, err := snowflake.NewNode(rand.Int63n(1024)) require.NoError(t, err) s := &Storage{ - codec: apitesting.TestCodec(codecs, v1alpha1.DashboardResourceInfo.GroupVersion()), + codec: apitesting.TestCodec(codecs, dashv1.DashboardResourceInfo.GroupVersion()), snowflake: node, opts: StorageOptions{ EnableFolderSupport: true, @@ -48,14 +48,14 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("Error on missing name", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} _, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.Error(t, err) require.Contains(t, err.Error(), "missing name") }) t.Run("Error on non-empty resource version", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" dashboard.ResourceVersion = "123" _, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) @@ -64,13 +64,13 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("Generate UID and leave deprecated ID empty, if not required", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) obj, err := utils.MetaAccessor(newObject) require.NoError(t, err) @@ -90,7 +90,7 @@ func TestPrepareObjectForStorage(t *testing.T) { ctx, _, err := identity.WithProvisioningIdentity(ctx, "default") require.NoError(t, err) - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" obj := dashboard.DeepCopyObject() meta, err := utils.MetaAccessor(obj) @@ -109,7 +109,7 @@ func TestPrepareObjectForStorage(t *testing.T) { encodedData, _, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) @@ -126,7 +126,7 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("Update should manage incrementing generation and metadata", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" obj := dashboard.DeepCopyObject() meta, err := utils.MetaAccessor(obj) @@ -136,7 +136,7 @@ func TestPrepareObjectForStorage(t *testing.T) { encodedData, _, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - insertedObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + insertedObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(insertedObject) require.NoError(t, err) @@ -156,8 +156,8 @@ func TestPrepareObjectForStorage(t *testing.T) { updatedObject := insertedObject.DeepCopyObject() meta, err = utils.MetaAccessor(updatedObject) require.NoError(t, err) - err = meta.SetStatus(v1alpha1.DashboardStatus{ - Conversion: &v1alpha1.DashboardConversionStatus{ + err = meta.SetStatus(dashv1.DashboardStatus{ + Conversion: &dashv1.DashboardConversionStatus{ Failed: true, Error: "test", }, @@ -172,7 +172,7 @@ func TestPrepareObjectForStorage(t *testing.T) { require.Equal(t, int64(1), meta.GetGeneration()) // Change the folder -- the generation should increase and the updatedBy metadata - dashboard2 := &v1alpha1.Dashboard{ObjectMeta: v1.ObjectMeta{ + dashboard2 := &dashv1.Dashboard{ObjectMeta: v1.ObjectMeta{ Name: dashboard.Name, }} // TODO... deep copy, See: https://github.com/grafana/grafana/pull/102258 meta2, err := utils.MetaAccessor(dashboard2) @@ -186,12 +186,12 @@ func TestPrepareObjectForStorage(t *testing.T) { s.opts.RequireDeprecatedInternalID = true t.Run("Should generate internal id", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) obj, err := utils.MetaAccessor(newObject) require.NoError(t, err) @@ -201,7 +201,7 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("Should use deprecated ID if given it", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" obj := dashboard.DeepCopyObject() meta, err := utils.MetaAccessor(obj) @@ -210,7 +210,7 @@ func TestPrepareObjectForStorage(t *testing.T) { encodedData, _, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) @@ -218,7 +218,7 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("Should remove grant permissions annotation", func(t *testing.T) { - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" obj := dashboard.DeepCopyObject() meta, err := utils.MetaAccessor(obj) @@ -227,7 +227,7 @@ func TestPrepareObjectForStorage(t *testing.T) { encodedData, p, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{}) + newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) @@ -236,11 +236,11 @@ func TestPrepareObjectForStorage(t *testing.T) { }) t.Run("calculate generation", func(t *testing.T) { - dash := &v1alpha1.Dashboard{ + dash := &dashv1.Dashboard{ ObjectMeta: v1.ObjectMeta{ Name: "test", }, - Spec: v1alpha1.DashboardSpec{ + Spec: dashv1.DashboardSpec{ Object: map[string]interface{}{ "hello": "world", }, @@ -286,8 +286,8 @@ func TestPrepareObjectForStorage(t *testing.T) { b.Labels = map[string]string{ "a": "b", } - b.Status = v1alpha1.DashboardStatus{ - Conversion: &v1alpha1.DashboardConversionStatus{ + b.Status = dashv1.DashboardStatus{ + Conversion: &dashv1.DashboardConversionStatus{ Failed: true, }, } @@ -320,13 +320,13 @@ func getPreparedObject(t *testing.T, ctx context.Context, s *Storage, obj runtim } func TestPrepareLargeObjectForStorage(t *testing.T) { - _ = v1alpha1.AddToScheme(scheme) + _ = dashv1.AddToScheme(scheme) node, err := snowflake.NewNode(rand.Int63n(1024)) require.NoError(t, err) ctx := authtypes.WithAuthInfo(context.Background(), &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser}) - dashboard := v1alpha1.Dashboard{} + dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" t.Run("Should deconstruct object if size is over threshold", func(t *testing.T) { los := LargeObjectSupportFake{ @@ -334,7 +334,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) { } f := &Storage{ - codec: apitesting.TestCodec(codecs, v1alpha1.DashboardResourceInfo.GroupVersion()), + codec: apitesting.TestCodec(codecs, dashv1.DashboardResourceInfo.GroupVersion()), snowflake: node, opts: StorageOptions{ LargeObjectSupport: &los, @@ -352,7 +352,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) { } f := &Storage{ - codec: apitesting.TestCodec(codecs, v1alpha1.DashboardResourceInfo.GroupVersion()), + codec: apitesting.TestCodec(codecs, dashv1.DashboardResourceInfo.GroupVersion()), snowflake: node, opts: StorageOptions{ LargeObjectSupport: &los, diff --git a/pkg/storage/unified/resource/cdk_bucket.go b/pkg/storage/unified/resource/cdk_bucket.go index 7a789c26eb1..e148326f8a9 100644 --- a/pkg/storage/unified/resource/cdk_bucket.go +++ b/pkg/storage/unified/resource/cdk_bucket.go @@ -19,10 +19,12 @@ type CDKBucket interface { ListPage(context.Context, []byte, int, *blob.ListOptions) ([]*blob.ListObject, []byte, error) WriteAll(context.Context, string, []byte, *blob.WriterOptions) error ReadAll(context.Context, string) ([]byte, error) + Delete(context.Context, string) error SignedURL(context.Context, string, *blob.SignedURLOptions) (string, error) } var _ CDKBucket = (*blob.Bucket)(nil) +var _ CDKBucket = (*InstrumentedBucket)(nil) const ( cdkBucketOperationLabel = "operation" @@ -159,6 +161,29 @@ func (b *InstrumentedBucket) WriteAll(ctx context.Context, key string, p []byte, return err } +func (b *InstrumentedBucket) Delete(ctx context.Context, key string) error { + ctx, span := b.tracer.Start(ctx, "InstrumentedBucket/Delete") + defer span.End() + start := time.Now() + err := b.bucket.Delete(ctx, key) + end := time.Since(start).Seconds() + labels := prometheus.Labels{ + cdkBucketOperationLabel: "Delete", + } + if err != nil { + labels[cdkBucketStatusLabel] = cdkBucketStatusError + b.requests.With(labels).Inc() + b.latency.With(labels).Observe(end) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + labels[cdkBucketStatusLabel] = cdkBucketStatusSuccess + b.requests.With(labels).Inc() + b.latency.With(labels).Observe(end) + return nil +} + func (b *InstrumentedBucket) SignedURL(ctx context.Context, key string, opts *blob.SignedURLOptions) (string, error) { ctx, span := b.tracer.Start(ctx, "InstrumentedBucket/SignedURL") defer span.End() diff --git a/pkg/storage/unified/resource/cdk_bucket_test.go b/pkg/storage/unified/resource/cdk_bucket_test.go index 71949d94f80..9a577a23864 100644 --- a/pkg/storage/unified/resource/cdk_bucket_test.go +++ b/pkg/storage/unified/resource/cdk_bucket_test.go @@ -12,6 +12,8 @@ import ( "gocloud.dev/blob" ) +var _ CDKBucket = (*fakeCDKBucket)(nil) + type fakeCDKBucket struct { attributesFunc func(ctx context.Context, key string) (*blob.Attributes, error) writeAllFunc func(ctx context.Context, key string, p []byte, opts *blob.WriterOptions) error @@ -19,6 +21,7 @@ type fakeCDKBucket struct { signedURLFunc func(ctx context.Context, key string, opts *blob.SignedURLOptions) (string, error) listFunc func(opts *blob.ListOptions) *blob.ListIterator listPageFunc func(ctx context.Context, pageToken []byte, pageSize int, opts *blob.ListOptions) ([]*blob.ListObject, []byte, error) + deleteFunc func(ctx context.Context, key string) error } func (f *fakeCDKBucket) Attributes(ctx context.Context, key string) (*blob.Attributes, error) { @@ -63,6 +66,13 @@ func (f *fakeCDKBucket) ListPage(ctx context.Context, pageToken []byte, pageSize return nil, nil, nil } +func (f *fakeCDKBucket) Delete(ctx context.Context, key string) error { + if f.deleteFunc != nil { + return f.deleteFunc(ctx, key) + } + return nil +} + func TestInstrumentedBucket(t *testing.T) { operations := []struct { name string @@ -108,6 +118,24 @@ func TestInstrumentedBucket(t *testing.T) { return err }, }, + { + name: "Delete", + operation: "Delete", + setup: func(fakeBucket *fakeCDKBucket, success bool) { + if success { + fakeBucket.deleteFunc = func(ctx context.Context, key string) error { + return nil + } + } else { + fakeBucket.deleteFunc = func(ctx context.Context, key string) error { + return fmt.Errorf("some error") + } + } + }, + call: func(instrumentedBucket *InstrumentedBucket) error { + return instrumentedBucket.Delete(context.Background(), "key") + }, + }, { name: "ReadAll", operation: "ReadAll", diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod index 5e9bf8bba61..a546f0c65e1 100644 --- a/pkg/storage/unified/resource/go.mod +++ b/pkg/storage/unified/resource/go.mod @@ -17,7 +17,6 @@ require ( github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250401081501-6af5fbf3fff0 - github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -32,7 +31,10 @@ require ( k8s.io/apimachinery v0.32.3 ) -require github.com/go-jose/go-jose/v3 v3.0.4 +require ( + github.com/go-jose/go-jose/v3 v3.0.4 + github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991 +) require ( cel.dev/expr v0.19.1 // indirect diff --git a/pkg/storage/unified/resource/go.sum b/pkg/storage/unified/resource/go.sum index c7635ad122f..8dfd755d99c 100644 --- a/pkg/storage/unified/resource/go.sum +++ b/pkg/storage/unified/resource/go.sum @@ -273,8 +273,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0/go.mod h1:jV+CTjXqXYuaz8FgSG7ALOib3sgiDo/00dfsQFVTSpM= github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043 h1:wdJy5x6M7auWDjUIubqhfZuZvphUMyjD7hxB3RqV4aE= github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043/go.mod h1:jwYig4wlnLLq4HQKDpS95nDeZi4+DmcD17KYYS1gMJg= -github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9 h1:kJWonBYechx35NbOUVf1ulufKyjH1UlDJDXlk8bdGn0= -github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250402082028-6781612335d9/go.mod h1:QdqsZpdCtg+3TEzXX3mUakjq79LFblZ8xliHmqZj3oA= +github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991 h1:3gcAHB3K1tSJ3jPY7EYm4G/TBimwcETyZVFQDsx18iA= +github.com/grafana/grafana/pkg/apis/folder v0.0.0-20250411131846-e7b32d622991/go.mod h1:Lmze5IWgV2qTGY0l1JQGRXpWoNGfeGX/HXdc8ByMfE4= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 921b09047c4..f6fa4e02475 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -16,8 +16,8 @@ import ( "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/runtime/schema" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" - folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + folders "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/authlib/types" ) @@ -563,7 +563,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size return err } } - return err + return iter.Error() }) return rv, err }) @@ -683,14 +683,14 @@ func AsResourceKey(ns string, t string) (*ResourceKey, error) { case "folders", "folder": return &ResourceKey{ Namespace: ns, - Group: folderv0alpha1.GROUP, - Resource: folderv0alpha1.RESOURCE, + Group: folders.GROUP, + Resource: folders.RESOURCE, }, nil case "dashboards", "dashboard": return &ResourceKey{ Namespace: ns, - Group: dashboardv1alpha1.GROUP, - Resource: dashboardv1alpha1.DASHBOARD_RESOURCE, + Group: dashboardv1.GROUP, + Resource: dashboardv1.DASHBOARD_RESOURCE, }, nil // NOT really supported in the dashboard search UI, but useful for manual testing diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 81fc94ae146..ab54a68fb61 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" ) @@ -32,15 +33,18 @@ type ResourceServer interface { } type ListIterator interface { + // Next advances iterator and returns true if there is next value is available from the iterator. + // Error() should be checked after every call of Next(), even when Next() returns true. Next() bool // sql.Rows - // Iterator error (if exts) + // Error returns iterator error, if any. This should be checked after any Next() call. + // (Some iterator implementations return true from Next, but also set the error at the same time). Error() error - // The token that can be used to start iterating *after* this item + // ContinueToken returns the token that can be used to start iterating *after* this item ContinueToken() string - // The token that can be used to start iterating *before* this item + // ContinueTokenWithCurrentRV returns the token that can be used to start iterating *before* this item ContinueTokenWithCurrentRV() string // ResourceVersion of the current item @@ -763,11 +767,10 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err if iter.Next() { rsp.NextPageToken = t } - - break + return iter.Error() } } - return nil + return iter.Error() }) if err != nil { rsp.Error = AsErrorResult(err) @@ -871,7 +874,7 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error { return err } } - return nil + return iter.Error() }) if err != nil { return err diff --git a/pkg/storage/unified/resource/testdata/folder-resource.json b/pkg/storage/unified/resource/testdata/folder-resource.json index 7344d8229cb..24835df9a17 100644 --- a/pkg/storage/unified/resource/testdata/folder-resource.json +++ b/pkg/storage/unified/resource/testdata/folder-resource.json @@ -1,6 +1,6 @@ { "kind": "Folder", - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "metadata": { "name": "ae2ntrqxefvnke", "namespace": "default", diff --git a/pkg/storage/unified/resource/testdata/folder-resource2.json b/pkg/storage/unified/resource/testdata/folder-resource2.json index f0b719ef16e..588954c1e1b 100644 --- a/pkg/storage/unified/resource/testdata/folder-resource2.json +++ b/pkg/storage/unified/resource/testdata/folder-resource2.json @@ -1,6 +1,6 @@ { "kind": "Folder", - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "metadata": { "name": "ae2ntrqxefvnke", "namespace": "default", diff --git a/pkg/storage/unified/search/dashboard.go b/pkg/storage/unified/search/dashboard.go index 888c2f0c4ab..a4b2275ad32 100644 --- a/pkg/storage/unified/search/dashboard.go +++ b/pkg/storage/unified/search/dashboard.go @@ -8,7 +8,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + dashV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -202,7 +202,7 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource. } } return resource.DocumentBuilderInfo{ - GroupResource: v1alpha1.DashboardResourceInfo.GroupResource(), + GroupResource: dashV1.DashboardResourceInfo.GroupResource(), Fields: fields, Namespaced: namespaced, }, err diff --git a/pkg/storage/unified/search/testdata/doc/folder-aaa.json b/pkg/storage/unified/search/testdata/doc/folder-aaa.json index 27ef4d28ffb..171e43eb982 100644 --- a/pkg/storage/unified/search/testdata/doc/folder-aaa.json +++ b/pkg/storage/unified/search/testdata/doc/folder-aaa.json @@ -1,6 +1,6 @@ { "kind": "Folder", - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "metadata": { "name": "aaa", "namespace": "default", diff --git a/pkg/storage/unified/search/testdata/doc/folder-bbb.json b/pkg/storage/unified/search/testdata/doc/folder-bbb.json index 81f97cc332a..1ebb4d00a43 100644 --- a/pkg/storage/unified/search/testdata/doc/folder-bbb.json +++ b/pkg/storage/unified/search/testdata/doc/folder-bbb.json @@ -1,6 +1,6 @@ { "kind": "Folder", - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "metadata": { "name": "bbb", "namespace": "default", diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index a81a69a95fa..e6e831dabc3 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/util/testutil" @@ -26,13 +27,14 @@ import ( // Test names for the storage backend test suite const ( - TestHappyPath = "happy path" - TestWatchWriteEvents = "watch write events from latest" - TestList = "list" - TestBlobSupport = "blob support" - TestGetResourceStats = "get resource stats" - TestListHistory = "list history" - TestCreateNewResource = "create new resource" + TestHappyPath = "happy path" + TestWatchWriteEvents = "watch write events from latest" + TestList = "list" + TestBlobSupport = "blob support" + TestGetResourceStats = "get resource stats" + TestListHistory = "list history" + TestListHistoryErrorReporting = "list history error reporting" + TestCreateNewResource = "create new resource" ) type NewBackendFunc func(ctx context.Context) resource.StorageBackend @@ -75,6 +77,7 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp {TestBlobSupport, runTestIntegrationBlobSupport}, {TestGetResourceStats, runTestIntegrationBackendGetResourceStats}, {TestListHistory, runTestIntegrationBackendListHistory}, + {TestListHistoryErrorReporting, runTestIntegrationBackendListHistoryErrorReporting}, {TestCreateNewResource, runTestIntegrationBackendCreateNewResource}, } @@ -476,7 +479,7 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend } func runTestIntegrationBackendListHistory(t *testing.T, backend resource.StorageBackend, nsPrefix string) { - ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) + ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) server := newServer(t, backend) ns := nsPrefix + "-ns1" rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED, WithNamespace(ns)) @@ -839,6 +842,58 @@ func runTestIntegrationBackendListHistory(t *testing.T, backend resource.Storage }) } +func runTestIntegrationBackendListHistoryErrorReporting(t *testing.T, backend resource.StorageBackend, nsPrefix string) { + ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + server := newServer(t, backend) + + ns := nsPrefix + "-short" + const ( + name = "it1" + group = "group" + resourceName = "resource" + ) + + start := time.Now() + origRv, _ := writeEvent(ctx, backend, name, resource.WatchEvent_ADDED, WithNamespace(ns), WithGroup(group), WithResource(resourceName)) + require.Greater(t, origRv, int64(0)) + + const events = 500 + prevRv := origRv + for i := 0; i < events; i++ { + rv, err := writeEvent(ctx, backend, name, resource.WatchEvent_MODIFIED, WithNamespace(ns), WithGroup(group), WithResource(resourceName)) + require.NoError(t, err) + require.Greater(t, rv, prevRv) + prevRv = rv + } + t.Log("added events in ", time.Since(start)) + + req := &resource.ListRequest{ + Limit: 2 * events, + Source: resource.ListRequest_HISTORY, + ResourceVersion: origRv, + VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: ns, + Group: group, + Resource: resourceName, + Name: name, + }, + }, + } + + shortContext, cancel := context.WithTimeout(ctx, 1*time.Millisecond) + defer cancel() + + res, err := server.List(shortContext, req) + // We expect context deadline error, but it may be reported as a res.Error object. + t.Log("list error:", err) + if res != nil { + t.Log("iterator error:", res.Error) + } + require.True(t, err != nil || (res != nil && res.Error != nil)) +} + func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) server := newServer(t, backend) diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index b653b3a8184..6727b533738 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/infra/db" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -131,10 +132,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -214,10 +220,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -315,10 +326,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -392,10 +408,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -480,10 +501,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -581,10 +607,15 @@ func TestIntegrationTestReceivers(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -687,10 +718,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) { "annotations": { "annotation1": "value1", "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana", "label1": "value1" } @@ -776,10 +812,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) { "alert": { "annotations": { "summary": "This is a custom annotation", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "TestAlert", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -863,10 +904,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) { "alert": { "annotations": { "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + "__dashboardUid__": "dashboard_uid", + "__orgId__": "1", + "__panelId__": "1", + "__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]", + "__values__": "{\"B\":22,\"C\":1}" }, "labels": { "alertname": "This is a custom label", + "grafana_folder": "Test Folder", "instance": "Grafana" } }, @@ -2491,6 +2537,7 @@ var expEmailNotifications = []*notifications.SendEmailCommandSync{ SilenceURL: "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%3DUID_EmailAlert&orgId=1", DashboardURL: "", PanelURL: "", + OrgID: util.Pointer(int64(1)), Values: map[string]float64{"A": 1}, ValueString: "[ var='A' labels={} value=1 ]", }, @@ -2654,7 +2701,8 @@ var expNonEmailNotifications = map[string][]string{ "fingerprint": "15c59b0a380bd9f1", "silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_WebhookAlert&orgId=1", "dashboardURL": "", - "panelURL": "" + "panelURL": "", + "orgId": 1 } ], "groupLabels": { diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 1a3fe0229fb..3551f7548f1 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" - folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + folders "github.com/grafana/grafana/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -221,8 +221,8 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with invalid schema", func(t *testing.T) { dashObj := &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "generateName": "test-", }, @@ -684,18 +684,18 @@ func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.O // getDashboardGVR returns the dashboard GroupVersionResource func getDashboardGVR() schema.GroupVersionResource { return schema.GroupVersionResource{ - Group: dashboardv1alpha1.DashboardResourceInfo.GroupVersion().Group, - Version: dashboardv1alpha1.DashboardResourceInfo.GroupVersion().Version, - Resource: dashboardv1alpha1.DashboardResourceInfo.GetName(), + Group: dashboardv1.DashboardResourceInfo.GroupVersion().Group, + Version: dashboardv1.DashboardResourceInfo.GroupVersion().Version, + Resource: dashboardv1.DashboardResourceInfo.GetName(), } } // getFolderGVR returns the folder GroupVersionResource func getFolderGVR() schema.GroupVersionResource { return schema.GroupVersionResource{ - Group: folderv0alpha1.FolderResourceInfo.GroupVersion().Group, - Version: folderv0alpha1.FolderResourceInfo.GroupVersion().Version, - Resource: folderv0alpha1.FolderResourceInfo.GetName(), + Group: folders.FolderResourceInfo.GroupVersion().Group, + Version: folders.FolderResourceInfo.GroupVersion().Version, + Resource: folders.FolderResourceInfo.GetName(), } } @@ -728,8 +728,8 @@ func createFolderObject(t *testing.T, title string, namespace string, parentFold folderObj := &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": folderv0alpha1.FolderResourceInfo.GroupVersion().String(), - "kind": folderv0alpha1.FolderResourceInfo.GroupVersionKind().Kind, + "apiVersion": folders.FolderResourceInfo.GroupVersion().String(), + "kind": folders.FolderResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "generateName": "test-folder-", "namespace": namespace, @@ -784,8 +784,8 @@ func createDashboardObject(t *testing.T, title string, folderUID string, generat dashObj := &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(), - "kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind, + "apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), + "kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, "metadata": map[string]interface{}{ "generateName": "test-", "annotations": map[string]interface{}{ diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 2b317103876..3ad3c82cdbb 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -18,7 +18,7 @@ import ( "github.com/grafana/grafana/pkg/api" "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/resourcepermissions" "github.com/grafana/grafana/pkg/services/dashboards" @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { var gvr = schema.GroupVersionResource{ Group: "folder.grafana.app", - Version: "v0alpha1", + Version: "v1", Resource: "folders", } @@ -55,7 +55,7 @@ func TestIntegrationFoldersApp(t *testing.T) { t.Run("Check discovery client", func(t *testing.T) { disco := helper.NewDiscoveryClient() - resources, err := disco.ServerResourcesForGroupVersion("folder.grafana.app/v0alpha1") + resources, err := disco.ServerResourcesForGroupVersion("folder.grafana.app/v1") require.NoError(t, err) v1Disco, err := json.MarshalIndent(resources, "", " ") @@ -64,7 +64,7 @@ func TestIntegrationFoldersApp(t *testing.T) { require.JSONEq(t, `{ "kind": "APIResourceList", "apiVersion": "v1", - "groupVersion": "folder.grafana.app/v0alpha1", + "groupVersion": "folder.grafana.app/v1", "resources": [ { "name": "folders", @@ -118,7 +118,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode0, }, }, @@ -134,7 +134,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -150,7 +150,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -167,7 +167,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -184,7 +184,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -201,7 +201,7 @@ func TestIntegrationFoldersApp(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -238,7 +238,7 @@ func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper require.NotEmpty(t, uid) expectedResult := `{ - "apiVersion": "folder.grafana.app/v0alpha1", + "apiVersion": "folder.grafana.app/v1", "kind": "Folder", "metadata": { "creationTimestamp": "${creationTimestamp}", @@ -571,7 +571,7 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -673,7 +673,7 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -850,7 +850,7 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode1, }, }, @@ -1020,7 +1020,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folderv0alpha1.RESOURCEGROUP: { + folders.RESOURCEGROUP: { DualWriterMode: modeDw, }, }, diff --git a/pkg/tests/apis/folder/testdata/folder-generate.yaml b/pkg/tests/apis/folder/testdata/folder-generate.yaml index a4c55dbaf5e..81e5185e0ad 100644 --- a/pkg/tests/apis/folder/testdata/folder-generate.yaml +++ b/pkg/tests/apis/folder/testdata/folder-generate.yaml @@ -1,4 +1,4 @@ -apiVersion: folder.grafana.app/v0alpha1 +apiVersion: folder.grafana.app/v1 kind: Folder metadata: generateName: x # anything is ok here... except yes or true -- they become boolean! diff --git a/pkg/tests/apis/folder/testdata/folder-test-create.yaml b/pkg/tests/apis/folder/testdata/folder-test-create.yaml index defcee9ac1e..f2483080dad 100644 --- a/pkg/tests/apis/folder/testdata/folder-test-create.yaml +++ b/pkg/tests/apis/folder/testdata/folder-test-create.yaml @@ -1,4 +1,4 @@ -apiVersion: folder.grafana.app/v0alpha1 +apiVersion: folder.grafana.app/v1 kind: Folder metadata: name: test diff --git a/pkg/tests/apis/folder/testdata/folder-test-replace.yaml b/pkg/tests/apis/folder/testdata/folder-test-replace.yaml index 493cd0db4c3..2eaf1e95403 100644 --- a/pkg/tests/apis/folder/testdata/folder-test-replace.yaml +++ b/pkg/tests/apis/folder/testdata/folder-test-replace.yaml @@ -1,4 +1,4 @@ -apiVersion: folder.grafana.app/v0alpha1 +apiVersion: folder.grafana.app/v1 kind: Folder metadata: name: test diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index a179f013d4d..556cc089928 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -3445,6 +3445,23 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions": { + "type": "object", + "required": [ + "mode", + "value" + ], + "properties": { + "mode": { + "type": "string", + "default": "" + }, + "value": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabsLayoutKind": { "type": "object", "required": [ @@ -3518,6 +3535,9 @@ "layout": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind" }, + "repeat": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions" + }, "title": { "type": "string" } diff --git a/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1.json b/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1.json new file mode 100644 index 00000000000..cca328392f3 --- /dev/null +++ b/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1.json @@ -0,0 +1,1822 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "Grafana folders", + "title": "folder.grafana.app/v1" + }, + "paths": { + "/apis/folder.grafana.app/v1/": { + "get": { + "tags": [ + "API Discovery" + ], + "description": "Describe the available kubernetes resources", + "operationId": "getAPIResources", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + } + } + } + } + } + }, + "/apis/folder.grafana.app/v1/namespaces/{namespace}/folders": { + "get": { + "tags": [ + "Folder" + ], + "description": "list objects of kind Folder", + "operationId": "listFolder", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "post": { + "tags": [ + "Folder" + ], + "description": "create a Folder", + "operationId": "createFolder", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "delete": { + "tags": [ + "Folder" + ], + "description": "delete collection of Folder", + "operationId": "deletecollectionFolder", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/folder.grafana.app/v1/namespaces/{namespace}/folders/{name}": { + "get": { + "tags": [ + "Folder" + ], + "description": "read the specified Folder", + "operationId": "getFolder", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "put": { + "tags": [ + "Folder" + ], + "description": "replace the specified Folder", + "operationId": "replaceFolder", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "delete": { + "tags": [ + "Folder" + ], + "description": "delete a Folder", + "operationId": "deleteFolder", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "patch": { + "tags": [ + "Folder" + ], + "description": "partially update the specified Folder", + "operationId": "updateFolder", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "Folder" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the Folder", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/folder.grafana.app/v1/namespaces/{namespace}/folders/{name}/access": { + "get": { + "tags": [ + "Folder" + ], + "description": "connect GET requests to access of Folder", + "operationId": "getFolderAccess", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderAccessInfo" + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "FolderAccessInfo" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the FolderAccessInfo", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/folder.grafana.app/v1/namespaces/{namespace}/folders/{name}/counts": { + "get": { + "tags": [ + "Folder" + ], + "description": "connect GET requests to counts of Folder", + "operationId": "getFolderCounts", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.DescendantCounts" + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "DescendantCounts" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the DescendantCounts", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/folder.grafana.app/v1/namespaces/{namespace}/folders/{name}/parents": { + "get": { + "tags": [ + "Folder" + ], + "description": "connect GET requests to parents of Folder", + "operationId": "getFolderParents", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderInfoList" + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "folder.grafana.app", + "version": "v1", + "kind": "FolderInfoList" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the FolderInfoList", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + } + }, + "components": { + "schemas": { + "com.github.grafana.grafana.pkg.apis.folder.v1.DescendantCounts": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "counts": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.ResourceStats" + } + ] + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "folder.grafana.app", + "kind": "DescendantCounts", + "version": "__internal" + }, + { + "group": "folder.grafana.app", + "kind": "DescendantCounts", + "version": "v1" + } + ] + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.Folder": { + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "spec": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Spec" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "folder.grafana.app", + "kind": "Folder", + "version": "__internal" + }, + { + "group": "folder.grafana.app", + "kind": "Folder", + "version": "v1" + } + ] + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.FolderAccessInfo": { + "description": "Access control information for the current user", + "type": "object", + "required": [ + "canSave", + "canEdit", + "canAdmin", + "canDelete" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "canAdmin": { + "type": "boolean", + "default": false + }, + "canDelete": { + "type": "boolean", + "default": false + }, + "canEdit": { + "type": "boolean", + "default": false + }, + "canSave": { + "type": "boolean", + "default": false + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "folder.grafana.app", + "kind": "FolderAccessInfo", + "version": "__internal" + }, + { + "group": "folder.grafana.app", + "kind": "FolderAccessInfo", + "version": "v1" + } + ] + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.FolderInfo": { + "description": "FolderInfo briefly describes a folder -- unlike a folder resource, this is a partial record of the folder metadata used for navigating parents and children", + "type": "object", + "required": [ + "name", + "title" + ], + "properties": { + "description": { + "description": "The folder description", + "type": "string" + }, + "detached": { + "description": "This folder does not resolve", + "type": "boolean" + }, + "name": { + "description": "Name is the k8s name (eg, the unique identifier) for a folder", + "type": "string", + "default": "" + }, + "parent": { + "description": "The parent folder UID", + "type": "string" + }, + "title": { + "description": "Title is the display value", + "type": "string", + "default": "" + } + } + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.FolderInfoList": { + "description": "FolderInfoList returns a list of folder references (parents or children) Unlike FolderList, each item is not a full k8s object", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.FolderInfo" + } + ] + }, + "x-kubernetes-list-map-keys": [ + "uid" + ], + "x-kubernetes-list-type": "map" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "folder.grafana.app", + "kind": "FolderInfoList", + "version": "__internal" + }, + { + "group": "folder.grafana.app", + "kind": "FolderInfoList", + "version": "v1" + } + ] + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.FolderList": { + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.folder.v1.Folder" + } + ] + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "folder.grafana.app", + "kind": "FolderList", + "version": "__internal" + }, + { + "group": "folder.grafana.app", + "kind": "FolderList", + "version": "v1" + } + ] + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.ResourceStats": { + "type": "object", + "required": [ + "group", + "resource", + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "group": { + "type": "string", + "default": "" + }, + "resource": { + "type": "string", + "default": "" + } + } + }, + "com.github.grafana.grafana.pkg.apis.folder.v1.Spec": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "description": "Describe the feature toggle", + "type": "string" + }, + "title": { + "description": "Describe the feature toggle", + "type": "string", + "default": "" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { + "description": "APIResource specifies the name of a resource and whether it is namespaced.", + "type": "object", + "required": [ + "name", + "singularName", + "namespaced", + "kind", + "verbs" + ], + "properties": { + "categories": { + "description": "categories is a list of the grouped resources this resource belongs to (e.g. 'all')", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale\".", + "type": "string" + }, + "kind": { + "description": "kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')", + "type": "string", + "default": "" + }, + "name": { + "description": "name is the plural name of the resource.", + "type": "string", + "default": "" + }, + "namespaced": { + "description": "namespaced indicates if a resource is namespaced or not.", + "type": "boolean", + "default": false + }, + "shortNames": { + "description": "shortNames is a list of suggested short names of the resource.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "singularName": { + "description": "singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface.", + "type": "string", + "default": "" + }, + "storageVersionHash": { + "description": "The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates.", + "type": "string" + }, + "verbs": { + "description": "verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy)", + "type": "array", + "items": { + "type": "string", + "default": "" + } + }, + "version": { + "description": "version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)\".", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList": { + "description": "APIResourceList is a list of APIResource, it is used to expose the name of the resources supported in a specific group and version, and if the resource is namespaced.", + "type": "object", + "required": [ + "groupVersion", + "resources" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "groupVersion": { + "description": "groupVersion is the group and version this APIResourceList is for.", + "type": "string", + "default": "" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "resources": { + "description": "resources contains the name of the resources and if they are namespaced.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResource" + } + ] + }, + "x-kubernetes-list-type": "atomic" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions": { + "description": "DeleteOptions may be provided when deleting an API object.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "dryRun": { + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "gracePeriodSeconds": { + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "type": "integer", + "format": "int64" + }, + "ignoreStoreReadErrorWithClusterBreakingPotential": { + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "type": "boolean" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "orphanDependents": { + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "type": "boolean" + }, + "preconditions": { + "description": "Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions" + } + ] + }, + "propagationPolicy": { + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1": { + "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:\u003cname\u003e', where \u003cname\u003e is the name of a field in a struct, or key in a map 'v:\u003cvalue\u003e', where \u003cvalue\u003e is the exact json formatted value of a list item 'i:\u003cindex\u003e', where \u003cindex\u003e is position of a item in a list 'k:\u003ckeys\u003e', where \u003ckeys\u003e is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta": { + "description": "ListMeta describes metadata that synthetic resources must have, including lists and various status objects. A resource may have only one of {ObjectMeta, ListMeta}.", + "type": "object", + "properties": { + "continue": { + "description": "continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message.", + "type": "string" + }, + "remainingItemCount": { + "description": "remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact.", + "type": "integer", + "format": "int64" + }, + "resourceVersion": { + "description": "String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry": { + "description": "ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the version of this resource that this field set applies to. The format is \"group/version\" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted.", + "type": "string" + }, + "fieldsType": { + "description": "FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: \"FieldsV1\"", + "type": "string" + }, + "fieldsV1": { + "description": "FieldsV1 holds the first JSON version format as described in the \"FieldsV1\" type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1" + } + ] + }, + "manager": { + "description": "Manager is an identifier of the workflow managing these fields.", + "type": "string" + }, + "operation": { + "description": "Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'.", + "type": "string" + }, + "subresource": { + "description": "Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource.", + "type": "string" + }, + "time": { + "description": "Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + "description": "ObjectMeta is metadata that all persisted resources must have, which includes all objects users must create.", + "type": "object", + "properties": { + "annotations": { + "description": "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "creationTimestamp": { + "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "deletionGracePeriodSeconds": { + "description": "Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only.", + "type": "integer", + "format": "int64" + }, + "deletionTimestamp": { + "description": "DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested.\n\nPopulated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "finalizers": { + "description": "Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set", + "x-kubernetes-patch-strategy": "merge" + }, + "generateName": { + "description": "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will return a 409.\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency", + "type": "string" + }, + "generation": { + "description": "A sequence number representing a specific generation of the desired state. Populated by the system. Read-only.", + "type": "integer", + "format": "int64" + }, + "labels": { + "description": "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "managedFields": { + "description": "ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like \"ci-cd\". The set of fields is always in the version that the workflow used when modifying the object.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "name": { + "description": "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string" + }, + "namespace": { + "description": "Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.\n\nMust be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces", + "type": "string" + }, + "ownerReferences": { + "description": "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference" + } + ] + }, + "x-kubernetes-list-map-keys": [ + "uid" + ], + "x-kubernetes-list-type": "map", + "x-kubernetes-patch-merge-key": "uid", + "x-kubernetes-patch-strategy": "merge" + }, + "resourceVersion": { + "description": "An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources.\n\nPopulated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + }, + "uid": { + "description": "UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations.\n\nPopulated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference": { + "description": "OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field.", + "type": "object", + "required": [ + "apiVersion", + "kind", + "name", + "uid" + ], + "properties": { + "apiVersion": { + "description": "API version of the referent.", + "type": "string", + "default": "" + }, + "blockOwnerDeletion": { + "description": "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.", + "type": "boolean" + }, + "controller": { + "description": "If true, this reference points to the managing controller.", + "type": "boolean" + }, + "kind": { + "description": "Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string", + "default": "" + }, + "name": { + "description": "Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string", + "default": "" + }, + "uid": { + "description": "UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string", + "default": "" + } + }, + "x-kubernetes-map-type": "atomic" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Patch": { + "description": "Patch is provided to give a concrete name and type to the Kubernetes PATCH request body.", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions": { + "description": "Preconditions must be fulfilled before an operation (update, delete, etc.) is carried out.", + "type": "object", + "properties": { + "resourceVersion": { + "description": "Specifies the target ResourceVersion", + "type": "string" + }, + "uid": { + "description": "Specifies the target UID.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Status": { + "description": "Status is a return value for calls that don't return other objects.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "code": { + "description": "Suggested HTTP return code for this status, 0 if not set.", + "type": "integer", + "format": "int32" + }, + "details": { + "description": "Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails" + } + ], + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "message": { + "description": "A human-readable description of the status of this operation.", + "type": "string" + }, + "metadata": { + "description": "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + }, + "reason": { + "description": "A machine-readable description of why this operation is in the \"Failure\" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it.", + "type": "string" + }, + "status": { + "description": "Status of the operation. One of: \"Success\" or \"Failure\". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause": { + "description": "StatusCause provides more information about an api.Status failure, including cases when multiple errors are encountered.", + "type": "object", + "properties": { + "field": { + "description": "The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.\n\nExamples:\n \"name\" - the field \"name\" on the current resource\n \"items[0].name\" - the field \"name\" on the first array entry in \"items\"", + "type": "string" + }, + "message": { + "description": "A human-readable description of the cause of the error. This field may be presented as-is to a reader.", + "type": "string" + }, + "reason": { + "description": "A machine-readable description of the cause of the error. If this value is empty there is no information available.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails": { + "description": "StatusDetails is a set of additional properties that MAY be set by the server to provide additional information about a response. The Reason field of a Status object defines what attributes will be set. Clients must ignore fields that do not match the defined type of each attribute, and should assume that any attribute may be empty, invalid, or under defined.", + "type": "object", + "properties": { + "causes": { + "description": "The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "The group attribute of the resource associated with the status StatusReason.", + "type": "string" + }, + "kind": { + "description": "The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "name": { + "description": "The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described).", + "type": "string" + }, + "retryAfterSeconds": { + "description": "If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action.", + "type": "integer", + "format": "int32" + }, + "uid": { + "description": "UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Time": { + "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", + "type": "string", + "format": "date-time" + } + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 45dd20e4530..82b2537f563 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -2472,6 +2472,24 @@ } } }, + "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "detail": { + "type": "string" + }, + "field": { + "type": "string" + }, + "type": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": { "type": "object", "properties": { @@ -3762,20 +3780,16 @@ "format": "int32", "default": 0 }, - "details": { - "description": "Optional details", - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured" - } - ] - }, "errors": { - "description": "Error descriptions", + "description": "Field related errors", "type": "array", "items": { - "type": "string", - "default": "" + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails" + } + ] } }, "kind": { diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index e2a9f7c9700..4ab3464d928 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -71,7 +71,7 @@ func TestIntegrationOpenAPIs(t *testing.T) { Version: "v2alpha1", }, { Group: "folder.grafana.app", - Version: "v0alpha1", + Version: "v1", }, { Group: "provisioning.grafana.app", Version: "v0alpha1", diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index c503d1c11aa..57ab7a1fabf 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -24,7 +24,7 @@ import ( "k8s.io/client-go/rest" dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" - folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + folder "github.com/grafana/grafana/pkg/apis/folder/v1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" @@ -48,6 +48,7 @@ type provisioningTestHelper struct { Folders *apis.K8sResourceClient Dashboards *apis.K8sResourceClient AdminREST *rest.RESTClient + EditorREST *rest.RESTClient ViewerREST *rest.RESTClient } @@ -256,13 +257,10 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper }) // Repo client, but less guard rails. Useful for subresources. We'll need this later... - restClient := helper.Org1.Admin.RESTClient(t, &schema.GroupVersion{ - Group: "provisioning.grafana.app", Version: "v0alpha1", - }) - - viewerClient := helper.Org1.Viewer.RESTClient(t, &schema.GroupVersion{ - Group: "provisioning.grafana.app", Version: "v0alpha1", - }) + gv := &schema.GroupVersion{Group: "provisioning.grafana.app", Version: "v0alpha1"} + adminClient := helper.Org1.Admin.RESTClient(t, gv) + editorClient := helper.Org1.Editor.RESTClient(t, gv) + viewerClient := helper.Org1.Viewer.RESTClient(t, gv) deleteAll := func(client *apis.K8sResourceClient) error { ctx := context.Background() @@ -287,7 +285,8 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper K8sTestHelper: helper, Repositories: repositories, - AdminREST: restClient, + AdminREST: adminClient, + EditorREST: editorClient, ViewerREST: viewerClient, Jobs: jobs, Folders: folders, diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 2ffaba5ea53..10beaf670e0 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -270,7 +270,21 @@ func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) { // Write a file -- this will create it *both* in the local file system, and in grafana t.Run("write all panels", func(t *testing.T) { code := 0 - result := helper.AdminREST.Post(). + + // Check that we can not (yet) UPDATE the target path + result := helper.AdminREST.Put(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", targetPath). + Body(helper.LoadFile("testdata/all-panels.json")). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&code) + require.Equal(t, http.StatusNotFound, code) + require.True(t, apierrors.IsNotFound(result.Error())) + + // Now try again with POST (as an editor) + result = helper.EditorREST.Post(). Namespace("default"). Resource("repositories"). Name(repo). diff --git a/pkg/util/xorm/dialect_spanner.go b/pkg/util/xorm/dialect_spanner.go index 0f18fb2e6f7..87c00a3447e 100644 --- a/pkg/util/xorm/dialect_spanner.go +++ b/pkg/util/xorm/dialect_spanner.go @@ -383,7 +383,7 @@ func (s *spanner) CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error) return nil, err } - if connectorConfig.Params["inMemSequenceGenerator"] == "true" { + if connectorConfig.Params[strings.ToLower("inMemSequenceGenerator")] == "true" { // Switch to using in-memory sequence number generator. // Using database-based sequence generator doesn't work with emulator, as emulator // only supports single transaction. If there is already another transaction started diff --git a/public/app/api/clients/provisioning/endpoints.gen.ts b/public/app/api/clients/provisioning/endpoints.gen.ts index 58fa48fb9f1..c665973eb50 100644 --- a/public/app/api/clients/provisioning/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/endpoints.gen.ts @@ -1096,15 +1096,18 @@ export type ResourceList = { kind?: string; metadata?: ListMeta; }; +export type ErrorDetails = { + detail?: string; + field?: string; + type: string; +}; export type TestResults = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; /** HTTP status code */ code: number; - /** Optional details */ - details?: Unstructured; - /** Error descriptions */ - errors?: string[]; + /** Field related errors */ + errors?: ErrorDetails[]; /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; /** Is the connection healthy */ diff --git a/public/app/api/clients/provisioning/index.ts b/public/app/api/clients/provisioning/index.ts index 3aeb73d552f..5695306ad6e 100644 --- a/public/app/api/clients/provisioning/index.ts +++ b/public/app/api/clients/provisioning/index.ts @@ -13,6 +13,7 @@ import { JobList, Repository, RepositoryList, + ErrorDetails, } from './endpoints.gen'; import { createOnCacheEntryAdded } from './utils/createOnCacheEntryAdded'; @@ -100,9 +101,11 @@ export const provisioningAPI = generatedAPI.enhanceEndpoints({ dispatch(notifyApp(createErrorNotification('Error validating repository', e))); } else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) { if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { - dispatch( - notifyApp(createErrorNotification('Error validating repository', e.error.data.errors.join('\n'))) - ); + const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field); + // Only show notification if there are errors that don't have a field, field errors are handled by the form + if (nonFieldErrors.length > 0) { + dispatch(notifyApp(createErrorNotification('Error validating repository'))); + } } } } diff --git a/public/app/app.ts b/public/app/app.ts index ef7460001e7..647124ce8dc 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -135,7 +135,7 @@ export class GrafanaApp { // This needs to be done after the `initEchoSrv` since it is being used under the hood. startMeasure('frontend_app_init'); - setLocale(config.bootData.user.locale); + setLocale(config.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); setPluginPage(PluginPage); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx index d7af125d894..4d8f7031db7 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx @@ -1,13 +1,15 @@ -import { useState } from 'react'; +import { css, cx } from '@emotion/css'; -import { Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Dropdown, Menu, ToolbarButton, useTheme2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { getComponentIdFromComponentMeta, useExtensionSidebarContext } from './ExtensionSidebarProvider'; export function ExtensionToolbarItem() { - const [isMenuOpen, setIsMenuOpen] = useState(false); + const styles = getStyles(useTheme2()); const { availableComponents, dockedComponentId, setDockedComponentId, isOpen, isEnabled } = useExtensionSidebarContext(); @@ -28,9 +30,9 @@ export function ExtensionToolbarItem() { return ( <> { if (isOpen) { @@ -68,15 +70,38 @@ export function ExtensionToolbarItem() { ); return ( <> - + ); } + +function getStyles(theme: GrafanaTheme2) { + return { + button: css({ + // this is needed because with certain breakpoints the button will get `width: auto` + // and the icon will stretch + aspectRatio: '1 / 1 !important', + width: '28px', + height: '28px', + padding: 0, + justifyContent: 'center', + borderRadius: theme.shape.radius.circle, + margin: theme.spacing(0, 0.25), + }), + buttonActive: css({ + borderRadius: theme.shape.radius.circle, + backgroundColor: theme.colors.primary.transparent, + border: `1px solid ${theme.colors.primary.borderTransparent}`, + color: theme.colors.text.primary, + }), + }; +} diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index 28cfb2d352c..fa49afb6bf4 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -142,6 +142,7 @@ "unicons/hourglass", "unicons/layer-group", "unicons/layers-alt", + "unicons/layers-slash", "unicons/line-alt", "unicons/list-ui-alt", "unicons/message", @@ -173,5 +174,9 @@ "mono/panel-add", "mono/library-panel", "unicons/record-audio", - "solid/bookmark" + "solid/bookmark", + "unicons/ai-sparkle", + "unicons/dollar-alt", + "unicons/window-grid", + "unicons/ban" ] diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index 8f0aaa14d7c..bdadd9ba863 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -2,16 +2,16 @@ import '@formatjs/intl-durationformat/polyfill'; import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; -import { getI18next } from './index'; +import { config } from 'app/core/config'; const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); -const createDateTimeFormatter = deepMemoize((language: string, options: Intl.DateTimeFormatOptions) => { - return new Intl.DateTimeFormat(language, options); +const createDateTimeFormatter = deepMemoize((locale: string, options: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(locale, options); }); -const createDurationFormatter = deepMemoize((language: string, options: Intl.DurationFormatOptions) => { - return new Intl.DurationFormat(language, options); +const createDurationFormatter = deepMemoize((locale: string, options: Intl.DurationFormatOptions) => { + return new Intl.DurationFormat(locale, options); }); export const formatDate = deepMemoize( @@ -20,17 +20,17 @@ export const formatDate = deepMemoize( return formatDate(new Date(value), format); } - const i18n = getI18next(); - const dateFormatter = createDateTimeFormatter(i18n.language, format); + const locale = config.locale; + const dateFormatter = createDateTimeFormatter(locale, format); return dateFormatter.format(value); } ); export const formatDuration = deepMemoize( (duration: Intl.DurationInput, options: Intl.DurationFormatOptions = {}): string => { - const i18n = getI18next(); + const locale = config.locale; - const dateFormatter = createDurationFormatter(i18n.language, options); + const dateFormatter = createDurationFormatter(locale, options); return dateFormatter.format(duration); } ); diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index e5f0ce7e597..ee7ba077400 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -34,23 +34,27 @@ const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger export { logError, logInfo, logMeasurement, logWarning }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withPerformanceLogging Promise>( - type: string, - func: TFunc, - context: Record -): (...args: Parameters) => Promise>> { - return async function (...args) { - const startLoadingTs = performance.now(); +/** + * Utility function to measure performance of async operations + * @param func Function to measure + * @param measurementName Name of the measurement for logging + * @param context Context for logging + */ +export function withPerformanceLogging( + func: (...args: TArgs) => Promise, + measurementName: string, + context: Record = {} +): (...args: TArgs) => Promise { + return async function (...args: TArgs): Promise { + const startMark = `${measurementName}:start`; + performance.mark(startMark); const response = await func(...args); - const loadTimesMs = performance.now() - startLoadingTs; + const loadTimeMeasure = performance.measure(measurementName, startMark); logMeasurement( - type, - { - loadTimesMs, - }, + measurementName, + { duration: loadTimeMeasure.duration, loadTimesMs: loadTimeMeasure.duration }, context ); diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index d48f5de0341..9e01d77d060 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -117,9 +117,6 @@ const promResponse: PromRulesResponse = { interval: 20, }, ], - totals: { - alerting: 2, - }, }, }; const rulerResponse = { diff --git a/public/app/features/alerting/unified/Settings.test.tsx b/public/app/features/alerting/unified/Settings.test.tsx index 401b4b6fee7..d086ba7663d 100644 --- a/public/app/features/alerting/unified/Settings.test.tsx +++ b/public/app/features/alerting/unified/Settings.test.tsx @@ -1,5 +1,4 @@ import { screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { render } from 'test/test-utils'; import { byRole, byTestId, byText } from 'testing-library-selector'; @@ -26,8 +25,9 @@ const ui = { statusReceiving: byText(/receiving grafana-managed alerts/i), statusNotReceiving: byText(/not receiving/i), - configurationDrawer: byRole('dialog', { name: 'Drawer title Internal Grafana Alertmanager' }), + configurationDrawer: byRole('dialog', { name: 'Drawer title Grafana built-in Alertmanager' }), editConfigurationButton: byRole('button', { name: /edit configuration/i }), + viewConfigurationButton: byRole('button', { name: /view configuration/i }), saveConfigurationButton: byRole('button', { name: /save/i }), enableButton: byRole('button', { name: 'Enable' }), @@ -54,7 +54,7 @@ describe('Alerting settings', () => { expect(ui.statusReceiving.get(ui.builtInAlertmanagerCard.get())).toBeInTheDocument(); - // check external altermanagers + // check external alertmanagers DataSourcesResponse.forEach((ds) => { // get the card for datasource const card = ui.alertmanagerCard(ds.name).get(); @@ -74,45 +74,36 @@ describe('Alerting settings', () => { }); it('should be able to view configuration', async () => { - render(); + const { user } = render(); // wait for loading to be done await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); // open configuration drawer const internalAMCard = ui.builtInAlertmanagerCard.get(); - const editInternal = ui.editConfigurationButton.get(internalAMCard); - await userEvent.click(editInternal); - - await waitFor(() => { - expect(ui.configurationDrawer.get()).toBeInTheDocument(); - }); - - await userEvent.click(ui.saveConfigurationButton.get()); - expect(ui.saveConfigurationButton.get()).toBeDisabled(); + await user.click(ui.viewConfigurationButton.get(internalAMCard)); + expect(await ui.configurationDrawer.find()).toBeInTheDocument(); - await waitFor(() => { - expect(ui.saveConfigurationButton.get()).toBeEnabled(); - }); + expect(ui.saveConfigurationButton.query()).not.toBeInTheDocument(); }); it('should be able to view versions', async () => { - render(); + const { user } = render(); // wait for loading to be done - await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); + expect(await ui.builtInAlertmanagerSection.find()).toBeInTheDocument(); // open configuration drawer const internalAMCard = ui.builtInAlertmanagerCard.get(); - const editInternal = ui.editConfigurationButton.get(internalAMCard); - await userEvent.click(editInternal); + await user.click(ui.viewConfigurationButton.get(internalAMCard)); + expect(await ui.configurationDrawer.find()).toBeInTheDocument(); await waitFor(() => { expect(ui.configurationDrawer.get()).toBeInTheDocument(); }); // click versions tab - await userEvent.click(ui.versionsTab.get()); + await user.click(ui.versionsTab.get()); await waitFor(() => { expect(screen.getByText(/last applied/i)).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index a6d1a718d83..0d57ea8b9ee 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -197,8 +197,8 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ // wrap our fetchConfig function with some performance logging functions const fetchAMconfigWithLogging = withPerformanceLogging( - 'unifiedalerting/fetchAmConfig', fetchAlertManagerConfig, + 'unifiedalerting/fetchAmConfig', { dataSourceName: alertmanagerSourceName, thunk: 'unifiedalerting/fetchAmConfig', diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx index 9ee0fae551b..3a7895c1a29 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx @@ -1,7 +1,7 @@ import 'core-js/stable/structured-clone'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render, screen } from 'test/test-utils'; +import { render, screen, within } from 'test/test-utils'; import EditContactPoint from 'app/features/alerting/unified/components/contact-points/EditContactPoint'; import { AccessControlAction } from 'app/types'; @@ -15,6 +15,20 @@ const Index = () => { return
redirected
; }; +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) { + return onBlur(e.currentTarget.value)} />; + }, +})); + +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) => + children({ height: 500, width: 400 }) +); + const renderEditContactPoint = (contactPointUid: string) => render( @@ -30,8 +44,7 @@ beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]); }); -const getTemplatePreviewContent = async () => - await screen.findByRole('presentation', { description: /Preview with the default payload/i }); +const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).getByTestId('mockeditor'); const templatesSelectorTestId = 'existing-templates-selector'; @@ -44,11 +57,11 @@ describe('Edit contact point', () => { await user.click(await screen.findByText(/optional email settings/i)); await user.click(await screen.findByRole('button', { name: /edit message/i })); expect(await screen.findByRole('dialog', { name: /edit message/i })).toBeInTheDocument(); - expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i); + expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`); // Change the preset template and check that the preview updates correctly await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'custom-email'); - expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for custom-email/i); + expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "custom-email" . }}`); // Close the drawer await user.click(screen.getByRole('button', { name: /^save$/i })); @@ -62,7 +75,7 @@ describe('Edit contact point', () => { await user.click(screen.getByRole('radio', { name: /select notification template/i })); await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'slack-template'); - expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i); + expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`); // Close the drawer await user.click(screen.getByRole('button', { name: /^save$/i })); diff --git a/public/app/features/alerting/unified/components/receivers/TemplateDataExamples.ts b/public/app/features/alerting/unified/components/receivers/TemplateDataExamples.ts index a53bb621b15..c82ea2f74aa 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateDataExamples.ts +++ b/public/app/features/alerting/unified/components/receivers/TemplateDataExamples.ts @@ -145,4 +145,43 @@ Alert annotations: {{ len .Annotations.SortedPairs }} - RunbookURL: {{ .Annotations.runbook_url}} {{ end -}}`, }, + { + description: 'Create JSON payload for webhook contact point', + example: `{{- /* Example displaying a custom JSON payload for a webhook contact point.*/ -}} +{{- /* Edit the template name and template content as needed. */ -}} +{{- /* Variables defined in the webhook contact point can be accessed in .Vars but will not be previewable. */ -}} +{{ define "webhook.custom.payload" -}} + {{ coll.Dict + "receiver" .Receiver + "status" .Status + "alerts" (tmpl.Exec "webhook.custom.simple_alerts" .Alerts | data.JSON) + "groupLabels" .GroupLabels + "commonLabels" .CommonLabels + "commonAnnotations" .CommonAnnotations + "externalURL" .ExternalURL + "version" "1" + "orgId" (index .Alerts 0).OrgID + "truncatedAlerts" .TruncatedAlerts + "groupKey" .GroupKey + "state" (tmpl.Inline "{{ if eq .Status \\"resolved\\" }}ok{{ else }}alerting{{ end }}" . ) + "allVariables" .Vars + "title" (tmpl.Exec "default.title" . ) + "message" (tmpl.Exec "default.message" . ) + | data.ToJSONPretty " "}} +{{- end }} + +{{- /* Example showcasing embedding json templates in other json templates. */ -}} +{{ define "webhook.custom.simple_alerts" -}} + {{- $alerts := coll.Slice -}} + {{- range . -}} + {{ $alerts = coll.Append (coll.Dict + "status" .Status + "labels" .Labels + "startsAt" .StartsAt + "endsAt" .EndsAt + ) $alerts}} + {{- end -}} + {{- $alerts | data.ToJSON -}} +{{- end }}`, + }, ]; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 4728dbdb312..bf2f7167bc6 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -336,6 +336,7 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props) ({ + ...jest.requireActual('@grafana/ui'), + CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) { + return onBlur(e.currentTarget.value)} />; + }, +})); + jest.mock( 'react-virtualized-auto-sizer', () => @@ -50,6 +57,7 @@ describe('TemplatePreview component', () => { , @@ -66,6 +74,7 @@ describe('TemplatePreview component', () => { , @@ -81,6 +90,7 @@ describe('TemplatePreview component', () => { , @@ -100,6 +110,7 @@ describe('TemplatePreview component', () => { , @@ -123,6 +134,7 @@ describe('TemplatePreview component', () => { , @@ -133,8 +145,11 @@ describe('TemplatePreview component', () => { await waitFor(() => { expect(previews()).toHaveLength(2); }); - expect(previews()[0]).toHaveTextContent('This is the template result bla bla bla'); - expect(previews()[1]).toHaveTextContent('This is the template2 result bla bla bla'); + const previewItems = previews(); + expect(within(previewItems[0]).getByRole('banner')).toHaveTextContent('template1'); + expect(within(previewItems[0]).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla'); + expect(within(previewItems[1]).getByRole('banner')).toHaveTextContent('template2'); + expect(within(previewItems[1]).getByTestId('mockeditor')).toHaveValue('This is the template2 result bla bla bla'); }); it('Should render preview response with some errors, if payload has correct format ', async () => { @@ -151,6 +166,7 @@ describe('TemplatePreview component', () => { , @@ -165,6 +181,39 @@ describe('TemplatePreview component', () => { expect(alerts()[1]).toHaveTextContent(/Unexpected "{" in operand/i); const previewContent = screen.getByRole('listitem'); - expect(previewContent).toHaveTextContent('This is the template result bla bla bla'); + expect(within(previewContent).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla'); + }); +}); + +it('Should render preview type , if response contains valid json ', async () => { + const response: TemplatePreviewResponse = { + results: [ + { name: 'template_text', text: 'This is the template result bla bla bla' }, + { name: 'template_valid', text: '{"test":"value","test2":"value2"}' }, + { name: 'template_invalid', text: '{"test":"value","test2":"value2",}' }, + ], + }; + mockPreviewTemplateResponse(server, response); + render( + , + { wrapper: getProviderWraper() } + ); + + const previews = ui.resultItems.getAll; + await waitFor(() => { + expect(previews()).toHaveLength(3); }); + const previewItems = previews(); + expect(within(previewItems[0]).getByRole('banner')).toHaveTextContent('template_text'); + expect(within(previewItems[0]).getByRole('banner')).toHaveTextContent('plaintext'); + expect(within(previewItems[1]).getByRole('banner')).toHaveTextContent('template_valid'); + expect(within(previewItems[1]).getByRole('banner')).toHaveTextContent('json'); + expect(within(previewItems[2]).getByRole('banner')).toHaveTextContent('template_invalid'); + expect(within(previewItems[2]).getByRole('banner')).toHaveTextContent('plaintext'); }); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index 179d01c3588..d9e86c083e7 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -1,39 +1,35 @@ import { css, cx } from '@emotion/css'; import { compact, uniqueId } from 'lodash'; import * as React from 'react'; -import { useFormContext } from 'react-hook-form'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Box, Button, useStyles2 } from '@grafana/ui'; +import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi'; import { stringifyErrorLike } from '../../utils/misc'; import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; -import type { TemplateFormValues } from './TemplateForm'; import { usePreviewTemplate } from './usePreviewTemplate'; export function TemplatePreview({ payload, templateName, + templateContent, payloadFormatError, setPayloadFormatError, className, }: { payload: string; templateName: string; + templateContent: string; payloadFormatError: string | null; setPayloadFormatError: (value: React.SetStateAction) => void; className?: string; }) { const styles = useStyles2(getStyles); - const { watch } = useFormContext(); - - const templateContent = watch('content'); - const { data, isLoading, @@ -73,14 +69,41 @@ function PreviewResultViewer({ previews }: { previews: TemplatePreviewResult[] } // If there is only one template, we don't need to show the name const singleTemplate = previews.length === 1; + const isValidJson = (text: string) => { + try { + JSON.parse(text); + return true; + } catch { + return false; + } + }; + return ( -
    - {previews.map((preview) => ( -
  • - {singleTemplate ? null :
    {preview.name}
    } -
    {preview.text ?? ''}
    -
  • - ))} +
      + {previews.map((preview) => { + const language = isValidJson(preview.text) ? 'json' : 'plaintext'; + return ( +
    • + {singleTemplate ? null : ( +
      + {preview.name} +
      {language}
      +
      + )} + +
    • + ); + })}
    ); } @@ -101,6 +124,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderRadius: theme.shape.radius.default, border: `1px solid ${theme.colors.border.medium}`, }), + editorContainer: css({ + width: '100%', + height: '100%', + border: 'none', + }), viewerContainer: ({ height }: { height: number }) => css({ height, @@ -120,20 +148,20 @@ const getStyles = (theme: GrafanaTheme2) => ({ height: 'inherit', }), header: css({ + display: 'flex', + justifyContent: 'space-between', fontSize: theme.typography.bodySmall.fontSize, padding: theme.spacing(1, 2), borderBottom: `1px solid ${theme.colors.border.medium}`, backgroundColor: theme.colors.background.secondary, }), + language: css({ + marginLeft: 'auto', + fontStyle: 'italic', + }), errorText: css({ color: theme.colors.error.text, }), - pre: css({ - backgroundColor: 'transparent', - margin: 0, - border: 'none', - padding: theme.spacing(2), - }), }, }); diff --git a/public/app/features/alerting/unified/components/receivers/editor/alertManagerSuggestions.ts b/public/app/features/alerting/unified/components/receivers/editor/alertManagerSuggestions.ts index 4a217a10cac..c92b4f3419c 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/alertManagerSuggestions.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/alertManagerSuggestions.ts @@ -1,6 +1,6 @@ import type { Monaco } from '@grafana/ui'; -import { AlertmanagerTemplateFunction } from './language'; +import { AlertmanagerTemplateFunction, GomplateFunctions } from './language'; import { SuggestionDefinition } from './suggestionDefinition'; export function getAlertManagerSuggestions(monaco: Monaco): SuggestionDefinition[] { @@ -50,3 +50,15 @@ export function getAlertManagerSuggestions(monaco: Monaco): SuggestionDefinition }, ]; } + +export function getGomplateSuggestions(monaco: Monaco): SuggestionDefinition[] { + const kind = monaco.languages.CompletionItemKind.Function; + return Object.values(GomplateFunctions).flatMap((functionList) => + functionList.map((func) => ({ + label: func.keyword, + detail: func.usage, + documentation: `${func.definition}\n\n${func.example}`, + kind, + })) + ); +} diff --git a/public/app/features/alerting/unified/components/receivers/editor/autocomplete.ts b/public/app/features/alerting/unified/components/receivers/editor/autocomplete.ts index edd248d5c52..e41b91db5ac 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/autocomplete.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/autocomplete.ts @@ -3,7 +3,7 @@ import type { IDisposable, IRange, Position, editor, languages } from 'monaco-ed import type { Monaco } from '@grafana/ui'; -import { getAlertManagerSuggestions } from './alertManagerSuggestions'; +import { getAlertManagerSuggestions, getGomplateSuggestions } from './alertManagerSuggestions'; import { SuggestionDefinition } from './suggestionDefinition'; import { getAlertSuggestions, @@ -49,17 +49,16 @@ export function registerGoTemplateAutocomplete(monaco: Monaco): IDisposable { } function isInsideGoExpression(model: editor.ITextModel, position: Position) { - const searchRange = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: model.getLineMinColumn(position.lineNumber), - endColumn: model.getLineMaxColumn(position.lineNumber), - }; - - const goSyntaxRegex = '\\{\\{[a-zA-Z0-9._() "]+\\}\\}'; - const matches = model.findMatches(goSyntaxRegex, searchRange, true, false, null, true); - - return matches.some((match) => match.range.containsPosition(position)); + // Need to trick findMatches into enabling multiline matches. One way to do this is to have \n in the regex. + const goSyntaxRegex = '\\{\\{(?:.|\\n)+?\\}\\}'; + const matches = model.findMatches(goSyntaxRegex, model.getFullModelRange(), true, false, null, false); + + return matches.some((match) => + match.range.containsPosition({ + lineNumber: position.lineNumber, + column: position.column + 1, // Stricter check to avoid matching on the closing bracket. + }) + ); } export class CompletionProvider { @@ -73,7 +72,10 @@ export class CompletionProvider { }; getFunctionsSuggestions = (): languages.ProviderResult => { - return this.getCompletionsFromDefinitions(getAlertManagerSuggestions(this.monaco)); + return this.getCompletionsFromDefinitions( + getAlertManagerSuggestions(this.monaco), + getGomplateSuggestions(this.monaco) + ); }; getTemplateDataSuggestions = (wordContext: string): languages.ProviderResult => { diff --git a/public/app/features/alerting/unified/components/receivers/editor/language.ts b/public/app/features/alerting/unified/components/receivers/editor/language.ts index c62e9df4eaa..ec1a5d237ee 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/language.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/language.ts @@ -26,7 +26,83 @@ export enum AlertmanagerTemplateFunction { stringSlice = 'stringSlice', } -export const availableAlertManagerFunctions = Object.values(AlertmanagerTemplateFunction); +// list of available Gomplate functions in Alertmanager templates +// see https://github.com/hairyhenderson/gomplate +export const GomplateFunctions = { + coll: [ + { + keyword: 'coll.Dict', + definition: + 'Creates a map with string keys from key/value pairs. All keys are converted to strings. If an odd number of arguments is provided, the last is used as the key with an empty string value.', + usage: 'function(key string, val any, ...)', + example: `{{ coll.Dict "name" "Frank" "age" 42 | data.ToYAML }}`, + }, + { + keyword: 'coll.Slice', + definition: 'Creates a slice (like an array or list). Useful when needing to range over a bunch of variables.', + usage: 'function(in ...any)', + example: `{{ range coll.Slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}`, + }, + { + keyword: 'coll.Append', + definition: 'Appends a value to the end of a list. Creates a new list rather than modifying the input.', + usage: 'function(value any, list []any)', + example: `{{ coll.Slice 1 1 2 3 | append 5 }}`, + }, + ], + + data: [ + { + keyword: 'data.JSON', + definition: 'Converts a JSON string into an object. Works for JSON Objects and Arrays.', + usage: 'function(json string)', + example: `{{ ($json | data.JSON).hello }}`, + }, + { + keyword: 'data.ToJSON', + definition: 'Converts an object to a JSON document.', + usage: 'function(obj any)', + example: `{{ (\`{"foo":{"hello":"world"}}\` | json).foo | data.ToJSON }}`, + }, + { + keyword: 'data.ToJSONPretty', + definition: 'Converts an object to a pretty-printed (indented) JSON document.', + usage: 'function(indent string, obj any)', + example: `{{ \`{"hello":"world"}\` | data.JSON | data.ToJSONPretty " " }}`, + }, + ], + + tmpl: [ + { + keyword: 'tmpl.Exec', + definition: + 'Execute (render) the named template. This is equivalent to using the `template` action, except the result is returned as a string. This allows for post-processing of templates.', + usage: 'function(name string, [context any])', + example: `{{ tmpl.Exec "T1" | strings.ToUpper }}`, + }, + { + keyword: 'tmpl.Inline', + definition: + 'Render the given string as a template, just like a nested template. If the template is given a name, it can be re-used later with the `template` keyword. A context can be provided, otherwise the default gomplate context will be used.', + usage: 'function(partial string, context any)', + example: `{{ tmpl.Inline "{{print \`hello world\`}}" }}`, + }, + ], + + time: [ + { + keyword: 'time.Now', + definition: "Returns the current local time, as a time.Time. This wraps Go's time.Now.", + usage: 'function()', + example: `{{ (time.Now).UTC.Format "Day 2 of month 1 in year 2006 (timezone MST)" }}`, + }, + ], +}; + +export const availableAlertManagerFunctions = [ + ...Object.values(AlertmanagerTemplateFunction), + ...Object.keys(GomplateFunctions).map((namespace) => namespace), +]; // boolean functions const booleanFunctions = ['eq', 'ne', 'lt', 'le', 'gt', 'ge']; diff --git a/public/app/features/alerting/unified/components/receivers/editor/snippets.ts b/public/app/features/alerting/unified/components/receivers/editor/snippets.ts index 2230a126977..f6d29dfaa35 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/snippets.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/snippets.ts @@ -28,6 +28,14 @@ Annotations: {{ end }} `; +export const jsonSnippet = ` +{{ coll.Dict + "receiver" .Receiver + "status" .Status + "alerts" ( len .Alerts ) +| data.ToJSONPretty " " }} +`; + export const groupLabelsLoopSnippet = getKeyValueTemplate('GroupLabels.SortedPairs'); export const commonLabelsLoopSnippet = getKeyValueTemplate('CommonLabels.SortedPairs'); export const commonAnnotationsLoopSnippet = getKeyValueTemplate('CommonAnnotations.SortedPairs'); diff --git a/public/app/features/alerting/unified/components/receivers/editor/templateDataSuggestions.ts b/public/app/features/alerting/unified/components/receivers/editor/templateDataSuggestions.ts index 6dedd8f9fe3..27c42e5507e 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/templateDataSuggestions.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/templateDataSuggestions.ts @@ -7,6 +7,7 @@ import { commonAnnotationsLoopSnippet, commonLabelsLoopSnippet, groupLabelsLoopSnippet, + jsonSnippet, labelsLoopSnippet, } from './snippets'; import { SuggestionDefinition } from './suggestionDefinition'; @@ -28,6 +29,8 @@ export function getGlobalSuggestions(monaco: Monaco): SuggestionDefinition[] { { label: 'CommonLabels', kind, detail: '[]KeyValue' }, { label: 'CommonAnnotations', kind, detail: '[]KeyValue' }, { label: 'ExternalURL', kind, detail: 'string' }, + { label: 'GroupKey', kind, detail: 'string' }, + { label: 'TruncatedAlerts', kind, detail: 'integer' }, ]; } @@ -104,6 +107,12 @@ export function getAlertSuggestions(monaco: Monaco): SuggestionDefinition[] { detail: 'string', documentation: 'String that contains labels and values of each reduced expression in the alert.', }, + { + label: { label: 'OrgID', detail: '(Alert)' }, + kind, + detail: 'integer', + documentation: 'The ID of the organization that owns the alert.', + }, ]; } @@ -169,6 +178,11 @@ export const snippets = { description: 'Renders a loop through annotations', snippet: annotationsLoopSnippet, }, + json: { + label: 'json', + description: 'Renders a JSON object', + snippet: jsonSnippet, + }, }; // Snippets @@ -176,7 +190,7 @@ export function getSnippetsSuggestions(monaco: Monaco): SuggestionDefinition[] { const snippetKind = monaco.languages.CompletionItemKind.Snippet; const snippetInsertRule = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; - const { alerts, alertDetails, groupLabels, commonLabels, commonAnnotations, labels, annotations } = snippets; + const { alerts, alertDetails, groupLabels, commonLabels, commonAnnotations, labels, annotations, json } = snippets; return [ { @@ -231,5 +245,12 @@ export function getSnippetsSuggestions(monaco: Monaco): SuggestionDefinition[] { insertText: annotations.snippet, insertTextRules: snippetInsertRule, }, + { + label: json.label, + documentation: json.description, + kind: snippetKind, + insertText: json.snippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace, + }, ]; } diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx index 08f6a107ab3..e5d06e8a7b7 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx @@ -10,8 +10,9 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader'; import { TemplateEditor } from '../../TemplateEditor'; -import { getPreviewResults } from '../../TemplatePreview'; -import { usePreviewTemplate } from '../../usePreviewTemplate'; +import { TemplatePreview } from '../../TemplatePreview'; + +import { getUseTemplateText } from './utils'; export function TemplateContentAndPreview({ payload, @@ -33,11 +34,6 @@ export function TemplateContentAndPreview({ const { selectedAlertmanager } = useAlertmanager(); const isGrafanaAlertManager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; - const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError); - const previewToRender = getPreviewResults(error, payloadFormatError, data); - - const templatePreviewId = 'template-preview'; - return (
    @@ -62,24 +58,15 @@ export function TemplateContentAndPreview({
    {isGrafanaAlertManager && ( -
    - - -
    - {previewToRender} -
    -
    -
    + )}
    ); @@ -98,6 +85,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderRadius: theme.shape.radius.default, border: `1px solid ${theme.colors.border.medium}`, }), + templatePreview: css({ + flex: 1, + display: 'flex', + }), + minEditorSize: css({ + minHeight: 300, + minWidth: 300, + }), viewerContainer: ({ height }: { height: number | string }) => css({ height, diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.tsx index dcd2516ebb4..5260695f6c1 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.tsx @@ -220,9 +220,7 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe /> - copyToClipboard(getUseTemplateText(template?.value?.name ?? defaultTemplateValue?.value?.name ?? '')) - } + onClick={() => copyToClipboard(template?.value?.content ?? defaultTemplateValue?.value?.content ?? '')} name="copy" /> diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index f2848617a65..d2039aa1d96 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -18,6 +18,7 @@ import { trackRulesSearchComponentInteraction, trackRulesSearchInputInteraction, } from '../../../Analytics'; +import { shouldUseAlertingListViewV2 } from '../../../featureToggles'; import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; import { RuleHealth } from '../../../search/rulesSearchParser'; @@ -40,6 +41,13 @@ const RuleHealthOptions: SelectableValue[] = [ { label: 'Error', value: RuleHealth.Error }, ]; +// Contact point selector is not supported in Alerting ListView V2 yet +const canRenderContactPointSelector = + (contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead) && + config.featureToggles.alertingSimplifiedRouting && + shouldUseAlertingListViewV2() === false) ?? + false; + interface RulesFilerProps { onClear?: () => void; } @@ -122,10 +130,6 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => { trackRulesSearchComponentInteraction('contactPoint'); }; - const canRenderContactPointSelector = - (contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead) && - config.featureToggles.alertingSimplifiedRouting) ?? - false; const searchIcon = ; return ( diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerCard.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerCard.tsx index e0aa3eadf64..8499c8161cc 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerCard.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerCard.tsx @@ -121,7 +121,11 @@ export function AlertmanagerCard({ {/* ⚠️ provisioned Data sources cannot have their "enable" / "disable" actions but we should still allow editing of the configuration */} {showActions ? ( <> diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx index c629073d589..5c1592ca098 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx @@ -47,20 +47,11 @@ describe('Alerting Settings', () => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]); }); - it('should be able to reset alertmanager config', async () => { + it('should not be able to reset alertmanager config', async () => { const onReset = jest.fn(); renderConfiguration('grafana', { onReset }); - await userEvent.click(await ui.resetButton.find()); - - await waitFor(() => { - expect(ui.resetConfirmButton.query()).toBeInTheDocument(); - }); - - await userEvent.click(ui.resetConfirmButton.get()); - - await waitFor(() => expect(onReset).toHaveBeenCalled()); - expect(onReset).toHaveBeenLastCalledWith('grafana'); + expect(ui.resetButton.query()).not.toBeInTheDocument(); }); it('should be able to cancel', async () => { @@ -100,4 +91,20 @@ describe('vanilla Alertmanager', () => { expect(ui.saveButton.get()).toBeInTheDocument(); expect(ui.resetButton.get()).toBeInTheDocument(); }); + + it('should be able to reset non-Grafana alertmanager config', async () => { + const onReset = jest.fn(); + renderConfiguration(PROVISIONED_MIMIR_ALERTMANAGER_UID, { onReset }); + + expect(ui.cancelButton.get()).toBeInTheDocument(); + expect(ui.saveButton.get()).toBeInTheDocument(); + expect(ui.resetButton.get()).toBeInTheDocument(); + + await userEvent.click(ui.resetButton.get()); + + await userEvent.click(ui.resetConfirmButton.get()); + + await waitFor(() => expect(onReset).toHaveBeenCalled()); + expect(onReset).toHaveBeenLastCalledWith(PROVISIONED_MIMIR_ALERTMANAGER_UID); + }); }); diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx index 05a02bcbec4..bd756fe9241 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx @@ -28,12 +28,12 @@ export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave const { loading: isDeleting, error: deletingError } = useUnifiedAlertingSelector((state) => state.deleteAMConfig); const { loading: isSaving, error: savingError } = useUnifiedAlertingSelector((state) => state.saveAMConfig); const [showResetConfirmation, setShowResetConfirmation] = useState(false); + const isGrafanaManagedAlertmanager = alertmanagerName === GRAFANA_RULES_SOURCE_NAME; // ⚠️ provisioned data sources should not prevent the configuration from being edited const immutableDataSource = alertmanagerName ? isVanillaPrometheusAlertManagerDataSource(alertmanagerName) : false; - const readOnly = immutableDataSource; + const readOnly = immutableDataSource || isGrafanaManagedAlertmanager; - const isGrafanaManagedAlertmanager = alertmanagerName === GRAFANA_RULES_SOURCE_NAME; const styles = useStyles2(getStyles); const { @@ -128,12 +128,28 @@ export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave ); } - const confirmationText = isGrafanaManagedAlertmanager - ? `Are you sure you want to reset configuration for the Grafana Alertmanager? Contact points and notification policies will be reset to their defaults.` - : `Are you sure you want to reset configuration for "${alertmanagerName}"? Contact points and notification policies will be reset to their defaults.`; + const confirmationText = t( + 'alerting.alertmanager-config.reset-confirmation', + 'Are you sure you want to reset configuration for "{{alertmanagerName}}"? Contact points and notification policies will be reset to their defaults.', + { alertmanagerName } + ); return (
    + {isGrafanaManagedAlertmanager && ( + + + The internal Grafana Alertmanager configuration cannot be manually changed. To change this configuration, + edit the individual resources through the UI. + + + )} {/* form error state */} {errors.configJSON && ( { setDataSourceName(dataSourceName); setOpen(true); @@ -33,14 +37,29 @@ export function useEditConfigurationDrawer() { } const isGrafanaAlertmanager = dataSourceName === GRAFANA_RULES_SOURCE_NAME; - const title = isGrafanaAlertmanager ? 'Internal Grafana Alertmanager' : dataSourceName; + const title = isGrafanaAlertmanager + ? t( + 'alerting.use-edit-configuration-drawer.drawer.internal-grafana-alertmanager-title', + 'Grafana built-in Alertmanager' + ) + : dataSourceName; + + const subtitle = readOnly + ? t( + 'alerting.use-edit-configuration-drawer.drawer.title-view-the-alertmanager-configuration', + 'View Alertmanager configuration' + ) + : t( + 'alerting.use-edit-configuration-drawer.drawer.title-edit-the-alertmanager-configuration', + 'Edit Alertmanager configuration' + ); // @todo check copy return ( ); - }, [open, dataSourceName, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]); + }, [open, dataSourceName, readOnly, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]); return [drawer, showConfiguration, handleDismiss] as const; } diff --git a/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx index add2e0f852d..b40619143f3 100644 --- a/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx +++ b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx @@ -30,6 +30,7 @@ export default function InternalAlertmanager({ onEditConfiguration }: Props) { onEditConfiguration={handleEditConfiguration} onEnable={handleEnable} onDisable={handleDisable} + readOnly /> ); } diff --git a/public/app/features/alerting/unified/components/settings/VersionManager.tsx b/public/app/features/alerting/unified/components/settings/VersionManager.tsx index b5285d5f990..9a47437094e 100644 --- a/public/app/features/alerting/unified/components/settings/VersionManager.tsx +++ b/public/app/features/alerting/unified/components/settings/VersionManager.tsx @@ -94,11 +94,15 @@ const AlertmanagerConfigurationVersionManager = ({ } if (isLoading) { - return 'Loading...'; + return Loading...; } if (!historicalConfigs.length) { - return 'No previous configurations'; + return ( + + No previous configurations + + ); } // with this function we'll compute the diff with the previous version; that way the user can get some idea of how many lines where changed in each update that was applied diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts index b95fc2dcaea..c02659dcab5 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts @@ -142,7 +142,7 @@ const getGrafanaAlertmanagerTemplatePreview = () => const body = await request.json(); if (body?.template.startsWith('{{')) { - return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.name}` }] }); + return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.template}` }] }); } return HttpResponse.json({}); diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx index 3671d13d232..059743c99a3 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx @@ -29,8 +29,6 @@ jest.mock('../api/ruler', () => ({ fetchRulerRulesNamespace: jest.fn(), })); -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it jest.mock('app/features/query/components/QueryEditorRow', () => ({ // eslint-disable-next-line react/display-name QueryEditorRow: () =>

    hi

    , diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx index 8e979828aa9..29c1f926dbe 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx @@ -39,8 +39,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
    {actions}
    , })); -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it jest.mock('app/features/query/components/QueryEditorRow', () => ({ // eslint-disable-next-line react/display-name QueryEditorRow: () =>

    hi

    , diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index 307954bf189..b9120e57eb1 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -40,17 +40,18 @@ beforeEach(() => { const io = mockIntersectionObserver(); describe('RuleList - FilterView', () => { - jest.setTimeout(60 * 1000); - jest.retryTimes(2); - it('should render multiple pages of results', async () => { render(); await loadMoreResults(); - expect(await screen.findAllByRole('treeitem')).toHaveLength(100); + const onePageResults = await screen.findAllByRole('treeitem'); + // FilterView loads rules in batches so it can load more than 100 rules for one page + expect(onePageResults.length).toBeGreaterThanOrEqual(100); await loadMoreResults(); - expect(await screen.findAllByRole('treeitem')).toHaveLength(200); + const twoPageResults = await screen.findAllByRole('treeitem'); + expect(twoPageResults.length).toBeGreaterThanOrEqual(200); + expect(twoPageResults.length).toBeGreaterThan(onePageResults.length); }); it('should filter results by group and rule name ', async () => { @@ -89,7 +90,7 @@ describe('RuleList - FilterView', () => { expect(matchingPrometheusRule).toBeInTheDocument(); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); - }, 90000); + }); it('should display empty state when no rules are found', async () => { render(); @@ -104,7 +105,7 @@ async function loadMoreResults() { act(() => { io.enterNode(screen.getByTestId('load-more-helper')); }); - await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 80000 }); + await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader')); } function getFilter(overrides: Partial = {}): RulesFilter { diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 900106ff86d..1e7253457b9 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -1,15 +1,17 @@ -import { empty } from 'ix/asynciterable'; -import { catchError, take, tap, withAbort } from 'ix/asynciterable/operators'; -import { useEffect, useRef, useState, useTransition } from 'react'; +import { bufferCountOrTime, tap } from 'ix/asynciterable/operators'; +import { useCallback, useMemo, useRef, useState, useTransition } from 'react'; +import { useUnmount } from 'react-use'; -import { Card, EmptyState, Stack, Text } from '@grafana/ui'; +import { EmptyState, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; +import { withPerformanceLogging } from '../Analytics'; import { isLoading, useAsync } from '../hooks/useAsync'; import { RulesFilter } from '../search/rulesSearchParser'; import { hashRule } from '../utils/rule-id'; import { DataSourceRuleLoader } from './DataSourceRuleLoader'; +import { FilterProgressState, FilterStatus } from './FilterViewStatus'; import { GrafanaRuleLoader } from './GrafanaRuleLoader'; import LoadMoreHelper from './LoadMoreHelper'; import { UnknownRuleListItem } from './components/AlertRuleListItem'; @@ -54,54 +56,85 @@ function FilterViewResults({ filterState }: FilterViewProps) { const [transitionPending, startTransition] = useTransition(); /* this hook returns a function that creates an AsyncIterable which we will use to populate the front-end */ - const { getFilteredRulesIterator } = useFilteredRulesIteratorProvider(); + const getFilteredRulesIterator = useFilteredRulesIteratorProvider(); - /* this is the abort controller that allows us to stop an AsyncIterable */ - const controller = useRef(new AbortController()); - - /** - * This an iterator that we can use to populate the search results. - * It also uses the signal from the AbortController above to cancel retrieving more results and sets up a - * callback function to detect when we've exhausted the source. - * This is the main AsyncIterable we will use for the search results */ - const rulesIterator = useRef( - getFilteredRulesIterator(filterState, API_PAGE_SIZE).pipe( - withAbort(controller.current.signal), - onFinished(() => setDoneSearching(true)) - ) - ); + const iteration = useRef<{ + rulesBatchIterator: AsyncIterator; + abortController: AbortController; + } | null>(null); const [rules, setRules] = useState([]); const [doneSearching, setDoneSearching] = useState(false); - /* This function will fetch a page of results from the iterable */ - const [{ execute: loadResultPage }, state] = useAsync(async () => { - for await (const rule of rulesIterator.current.pipe( - // grab from the rules iterable - take(FRONTENT_PAGE_SIZE), - // if an error occurs trying to fetch a page, return an empty iterable so the front-end isn't caught in an infinite loop - catchError(() => empty()) - )) { - startTransition(() => { - // Rule key could be computed on the fly, but we do it here to avoid recalculating it with each render - // It's a not trivial computation because it involves hashing the rule - setRules((rules) => rules.concat({ key: getRuleKey(rule), ...rule })); - }); + // Lazy initialization of useRef + // https://18.react.dev/reference/react/useRef#how-to-avoid-null-checks-when-initializing-use-ref-later + const getRulesBatchIterator = useCallback(() => { + if (!iteration.current) { + /** + * This an iterator that we can use to populate the search results. + * It also uses the signal from the AbortController above to cancel retrieving more results and sets up a + * callback function to detect when we've exhausted the source. + * This is the main AsyncIterable we will use for the search results + * + * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint + * and the iterator will allow us to exhaust the iterable in a stateful way + */ + const { iterable, abortController } = getFilteredRulesIterator(filterState, API_PAGE_SIZE); + const rulesBatchIterator = iterable + .pipe( + bufferCountOrTime(FRONTENT_PAGE_SIZE, 1000), + onFinished(() => setDoneSearching(true)) + ) + [Symbol.asyncIterator](); + iteration.current = { rulesBatchIterator: rulesBatchIterator, abortController }; } - }); - - /* When we unmount the component we make sure to abort all iterables */ - useEffect(() => { - const currentAbortController = controller.current; + return iteration.current.rulesBatchIterator; + }, [filterState, getFilteredRulesIterator]); - return () => { - currentAbortController.abort(); - }; - }, [controller]); + /* This function will fetch a page of results from the iterable */ + const [{ execute: loadResultPage }, state] = useAsync( + withPerformanceLogging(async () => { + const rulesIterator = getRulesBatchIterator(); + + let loadedRulesCount = 0; + + while (loadedRulesCount < FRONTENT_PAGE_SIZE) { + const nextRulesBatch = await rulesIterator.next(); + if (nextRulesBatch.done) { + return; + } + if (nextRulesBatch.value) { + startTransition(() => { + setRules((rules) => rules.concat(nextRulesBatch.value.map((rule) => ({ key: getRuleKey(rule), ...rule })))); + }); + } + loadedRulesCount += nextRulesBatch.value.length; + } + }, 'alerting.rule-list.filter-view.load-result-page') + ); const loading = isLoading(state) || transitionPending; const numberOfRules = rules.length; const noRulesFound = numberOfRules === 0 && !loading; + const loadingAborted = iteration.current?.abortController.signal.aborted; + const cancelSearch = useCallback(() => { + iteration.current?.abortController.abort(); + }, []); + + /* When we unmount the component we make sure to abort all iterables and stop making HTTP requests */ + useUnmount(() => { + cancelSearch(); + }); + + // track the state of the filter progress, which is either searching, done or aborted + const filterProgressState = useMemo(() => { + if (loadingAborted) { + return 'aborted'; + } else if (doneSearching) { + return 'done'; + } + return 'searching'; + }, [doneSearching, loadingAborted]); /* If we don't have any rules and have exhausted all sources, show a EmptyState */ if (noRulesFound && doneSearching) { @@ -150,16 +183,10 @@ function FilterViewResults({ filterState }: FilterViewProps) { )}
- {doneSearching && !noRulesFound && ( - - - - No more results – showing {{ numberOfRules }} rules - - - + {!noRulesFound && ( + )} - {!doneSearching && !loading && } + {!doneSearching && !loading && !loadingAborted && } ); } diff --git a/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx b/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx new file mode 100644 index 00000000000..c72a5a72ce8 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx @@ -0,0 +1,41 @@ +import { Button, Card, Text } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +export type FilterProgressState = 'searching' | 'done' | 'aborted'; +interface FilterStatusProps { + numberOfRules: number; + state: FilterProgressState; + onCancel: () => void; +} + +export function FilterStatus({ state, numberOfRules, onCancel }: FilterStatusProps) { + return ( + + + {/* done searching everything and found some results */} + {state === 'done' && ( + + No more results – found {{ numberOfRules }} rules + + )} + {/* user has cancelled the search */} + {state === 'aborted' && ( + + Search cancelled – found {{ numberOfRules }} rules + + )} + {/* search is in progress */} + {state === 'searching' && ( + + Searching – found {{ numberOfRules }} rules + + )} + + {state === 'searching' && ( + + )} + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx index 7444e303fb8..5cb1b7ebf83 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx @@ -1,3 +1,5 @@ +import { Alert } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; import { GrafanaPromRuleDTO, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; @@ -26,21 +28,40 @@ interface GrafanaRuleLoaderProps { } export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) { - const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery({ + const { + data: rulerRuleGroup, + isError, + isLoading, + } = useGetGrafanaRulerGroupQuery({ folderUid: groupIdentifier.namespace.uid, groupName: groupIdentifier.groupName, }); const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === rule.uid); - if (!rulerRule) { - if (isError) { - return ; - } + if (isError) { + return ; + } + if (isLoading) { return ; } + if (!rulerRule) { + return ( + + + Cannot find rule details for {{ uid: rule.uid ?? '' }} + + + ); + } + return ( { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 59ecd1f31ac..755937c2773 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -14,7 +14,7 @@ import { LazyPagination } from './components/LazyPagination'; import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu'; -import { useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; +import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups'; const GRAFANA_GROUP_PAGE_SIZE = 40; @@ -22,7 +22,7 @@ const GRAFANA_GROUP_PAGE_SIZE = 40; export function PaginatedGrafanaLoader() { const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true }); - const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE)); + const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE))); useEffect(() => { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts new file mode 100644 index 00000000000..18e4af4f40f --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts @@ -0,0 +1,257 @@ +import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; +import { RuleHealth } from '../../search/rulesSearchParser'; +import { Annotation } from '../../utils/constants'; +import * as datasourceUtils from '../../utils/datasource'; +import { getFilter } from '../../utils/search'; + +import { groupFilter, ruleFilter } from './filters'; + +describe('groupFilter', () => { + it('should filter by namespace (file path)', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({ namespace: 'production' }))).toBe(true); + expect(groupFilter(group, getFilter({ namespace: 'staging' }))).toBe(false); + }); + + it('should filter by group name', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({ groupName: 'cpu' }))).toBe(true); + expect(groupFilter(group, getFilter({ groupName: 'memory' }))).toBe(false); + }); + + it('should return true when no filters are applied', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({}))).toBe(true); + }); +}); + +describe('ruleFilter', () => { + it('should filter by free form words in rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false); + }); + + it('should filter by rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }))).toBe(true); + expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false); + }); + + it('should filter by labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + expect(ruleFilter(rule, getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['severity=warning'] }))).toBe(false); + expect(ruleFilter(rule, getFilter({ labels: ['team=ops'] }))).toBe(true); + }); + + it('should filter by alert instance labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical' }, + alerts: [ + { + labels: { instance: 'server-1', env: 'production' }, + state: PromAlertingRuleState.Firing, + value: '100', + activeAt: '', + annotations: {}, + }, + ], + }); + + expect(ruleFilter(rule, getFilter({ labels: ['instance=server-1'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['env=production'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['instance=server-2'] }))).toBe(false); + }); + + it('should filter by rule type', () => { + const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(true); + expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + }); + + it('should filter by rule state', () => { + const firingRule = mockPromAlertingRule({ + name: 'Firing Alert', + state: PromAlertingRuleState.Firing, + }); + + const pendingRule = mockPromAlertingRule({ + name: 'Pending Alert', + state: PromAlertingRuleState.Pending, + }); + + expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(true); + expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); + expect(ruleFilter(pendingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(true); + }); + + it('should filter out recording rules when filtering by rule state', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + }); + + // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Inactive }))).toBe(false); + }); + + it('should filter by rule health', () => { + const healthyRule = mockPromAlertingRule({ + name: 'Healthy Rule', + health: RuleHealth.Ok, + }); + + const errorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: RuleHealth.Error, + }); + + expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(true); + expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(false); + expect(ruleFilter(errorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); + }); + + it('should filter by dashboard UID', () => { + const ruleDashboardA = mockPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-a' }))).toBe(true); + expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(false); + expect(ruleFilter(ruleDashboardB, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(true); + }); + + it('should filter out recording rules when filtering by dashboard UID', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + // Recording rules cannot have dashboard UIDs because they don't have annotations + }); + + // Dashboard UID filter should filter out recording rules + expect(ruleFilter(recordingRule, getFilter({ dashboardUid: 'any-dashboard' }))).toBe(false); + }); + + describe('dataSourceNames filter', () => { + let getDataSourceUIDSpy: jest.SpyInstance; + + beforeEach(() => { + getDataSourceUIDSpy = jest.spyOn(datasourceUtils, 'getDatasourceAPIUid').mockImplementation((ruleSourceName) => { + if (ruleSourceName === 'prometheus') { + return 'datasource-uid-1'; + } + if (ruleSourceName === 'loki') { + return 'datasource-uid-3'; + } + throw new Error(`Unknown datasource name: ${ruleSourceName}`); + }); + }); + + afterEach(() => { + // Clean up + getDataSourceUIDSpy.mockRestore(); + }); + + it('should match rules that use the filtered datasource', () => { + // Create a Grafana rule with matching datasource + const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1'], + }); + + // 'prometheus' resolves to 'datasource-uid-1' which is in the rule + expect(ruleFilter(ruleWithMatchingDatasource, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + }); + + it("should filter out rules that don't use the filtered datasource", () => { + // Create a Grafana rule without the target datasource + const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + }); + + // 'loki' resolves to 'datasource-uid-3' which is not in the rule + expect(ruleFilter(ruleWithoutMatchingDatasource, getFilter({ dataSourceNames: ['loki'] }))).toBe(false); + }); + + it('should return false when there is an error parsing the query', () => { + const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ + query: 'not-valid-json', + }); + + expect(ruleFilter(ruleWithInvalidQuery, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); + }); + }); + + it('should combine multiple filters with AND logic', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=critical', 'environment=production'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + expect(ruleFilter(rule, filter)).toBe(true); + }); + + it('should return false if any filter does not match', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + alerts: [], + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=warning'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + expect(ruleFilter(rule, filter)).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts new file mode 100644 index 00000000000..89eb4c96f6b --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -0,0 +1,144 @@ +import { attempt, compact, isString } from 'lodash'; +import memoize from 'micro-memoize'; + +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; +import { labelsMatchMatchers } from '../../utils/alertmanager'; +import { Annotation } from '../../utils/constants'; +import { getDatasourceAPIUid } from '../../utils/datasource'; +import { parseMatcher } from '../../utils/matchers'; +import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; + +/** + * @returns True if the group matches the filter, false otherwise. Keeps rules intact + */ +export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { + const { name, file } = group; + + // Add fuzzy search for namespace + if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { + return false; + } + + // Add fuzzy search for group name + if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { + return false; + } + + return true; +} + +/** + * @returns True if the rule matches the filter, false otherwise + */ +export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { + const { name, labels = {}, health, type } = rule; + + const nameLower = name.toLowerCase(); + + // Free form words filter (matches if any word is part of the rule name) + if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { + return false; + } + + // Rule name filter (exact match) + if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { + return false; + } + + // Labels filter + if (filterState.labels.length > 0) { + const matchers = compact(filterState.labels.map(looseParseMatcher)); + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); + + // Also check alerts if they exist + const doAlertsContainMatchingLabels = + matchers.length > 0 && + prometheusRuleType.alertingRule(rule) && + rule.alerts && + rule.alerts.some((alert) => labelsMatchMatchers(alert.labels || {}, matchers)); + + if (!doRuleLabelsMatchQuery && !doAlertsContainMatchingLabels) { + return false; + } + } + + // Rule type filter + if (filterState.ruleType && type !== filterState.ruleType) { + return false; + } + + // Rule state filter (for alerting rules only) + if (filterState.ruleState) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + if (rule.state !== filterState.ruleState) { + return false; + } + } + + // Rule health filter + if (filterState.ruleHealth && health !== filterState.ruleHealth) { + return false; + } + + // Dashboard UID filter + if (filterState.dashboardUid) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + + const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; + if (dashboardAnnotation !== filterState.dashboardUid) { + return false; + } + } + + // Plugins filter - hide plugin-provided rules when set to 'hide' + if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { + return false; + } + + // Note: We can't implement these filters from reduceGroups because they rely on rulerRule property + // which is not available in PromRuleDTO: + // - contactPoint filter + // - dataSourceNames filter + if (filterState.dataSourceNames.length > 0) { + const isGrafanaRule = prometheusRuleType.grafana.rule(rule); + if (isGrafanaRule) { + try { + const filterDatasourceUids = mapDataSourceNamesToUids(filterState.dataSourceNames); + const queriedDatasourceUids = rule.queriedDatasourceUIDs || []; + + const queryIncludesDataSource = queriedDatasourceUids.some((uid) => filterDatasourceUids.includes(uid)); + if (!queryIncludesDataSource) { + return false; + } + } catch (error) { + return false; + } + } + } + + return true; +} + +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times +const mapDataSourceNamesToUids = memoize( + (names: string[]): string[] => { + return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString); + }, + { maxSize: 1 } +); diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index 39339caa91e..2087e31c1c4 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useDispatch } from 'app/types/store'; import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting'; +import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi'; @@ -95,6 +96,23 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = ); } +/** + * Converts a Prometheus groups generator yielding arrays of groups to a generator yielding groups one by one + * @param generator - The paginated generator to convert + * @returns A non-paginated generator that yields all groups from the original generator one by one + */ +export function toIndividualRuleGroups( + generator: AsyncGenerator +): AsyncGenerator { + return (async function* () { + for await (const batch of generator) { + for (const item of batch) { + yield item; + } + } + })(); +} + // Generator lazily provides groups one by one only when needed // This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources // For unpaginated data sources we fetch everything in one go @@ -104,14 +122,13 @@ async function* genericGroupsGenerator( groupLimit: number ) { let response = await fetchGroups({ groupLimit }); - yield* response.data.groups; + yield response.data.groups; let lastToken: string | undefined = response.data?.groupNextToken; while (lastToken) { response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit }); - - yield* response.data.groups; + yield response.data.groups; lastToken = response.data?.groupNextToken; } } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 8c494c0545a..50be4d9054d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -1,9 +1,8 @@ import { AsyncIterableX, empty, from } from 'ix/asynciterable'; import { merge } from 'ix/asynciterable/merge'; -import { catchError, filter, flatMap, map } from 'ix/asynciterable/operators'; -import { compact } from 'lodash'; +import { catchError, concatMap, withAbort } from 'ix/asynciterable/operators'; +import { isEmpty } from 'lodash'; -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, @@ -17,12 +16,14 @@ import { } from 'app/types/unified-alerting-dto'; import { RulesFilter } from '../../search/rulesSearchParser'; -import { labelsMatchMatchers } from '../../utils/alertmanager'; -import { Annotation } from '../../utils/constants'; -import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource'; -import { parseMatcher } from '../../utils/matchers'; -import { prometheusRuleType } from '../../utils/rules'; +import { + getDataSourceByUid, + getDatasourceAPIUid, + getExternalRulesSources, + isSupportedExternalRulesSourceType, +} from '../../utils/datasource'; +import { groupFilter, ruleFilter } from './filters'; import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -44,54 +45,97 @@ export interface PromRuleWithOrigin { origin: 'datasource'; } +interface GetIteratorResult { + iterable: AsyncIterableX; + abortController: AbortController; +} + export function useFilteredRulesIteratorProvider() { const allExternalRulesSources = getExternalRulesSources(); const prometheusGroupsGenerator = usePrometheusGroupsGenerator(); const grafanaGroupsGenerator = useGrafanaGroupsGenerator(); - const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number): AsyncIterableX => { + const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => { + /* this is the abort controller that allows us to stop an AsyncIterable */ + const abortController = new AbortController(); + const normalizedFilterState = normalizeFilterState(filterState); + const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length); + + const grafanaRulesGenerator = from(grafanaGroupsGenerator(groupLimit)).pipe( + withAbort(abortController.signal), + concatMap((groups) => + groups + .filter((group) => groupFilter(group, normalizedFilterState)) + .flatMap((group) => group.rules.map((rule) => [group, rule] as const)) + .filter(([, rule]) => ruleFilter(rule, normalizedFilterState)) + .map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)) + ), + catchError(() => empty()) + ); - const ruleSourcesToFetchFrom = filterState.dataSourceNames.length - ? filterState.dataSourceNames.map((ds) => ({ - name: ds, - uid: getDatasourceAPIUid(ds), - ruleSourceType: 'datasource', - })) + // Determine which data sources to use + const externalRulesSourcesToFetchFrom = hasDataSourceFilterActive + ? getRulesSourcesFromFilter(filterState) : allExternalRulesSources; - const grafanaIterator = from(grafanaGroupsGenerator(groupLimit)).pipe( - filter((group) => groupFilter(group, normalizedFilterState)), - flatMap((group) => group.rules.map((rule) => [group, rule] as const)), - filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)), - map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)), - catchError(() => empty()) - ); + // If no data sources, just return Grafana rules + if (isEmpty(externalRulesSourcesToFetchFrom)) { + return { iterable: grafanaRulesGenerator, abortController }; + } - const sourceIterables = ruleSourcesToFetchFrom.map((ds) => { - const generator = prometheusGroupsGenerator(ds, groupLimit); - return from(generator).pipe( - map((group) => [ds, group] as const), + // Create a generator for each data source + const dataSourceGenerators = externalRulesSourcesToFetchFrom.map((dataSourceIdentifier) => { + const promGroupsGenerator = from(prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)).pipe( + withAbort(abortController.signal), + concatMap((groups) => + groups + .filter((group) => groupFilter(group, normalizedFilterState)) + .flatMap((group) => group.rules.map((rule) => [group, rule] as const)) + .filter(([, rule]) => ruleFilter(rule, normalizedFilterState)) + .map(([group, rule]) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule)) + ), catchError(() => empty()) ); - }); - // if we have no prometheus data sources, use an empty async iterable - const source = sourceIterables.at(0) ?? empty(); - const otherIterables = sourceIterables.slice(1); - - const dataSourcesIterator = merge(source, ...otherIterables).pipe( - filter(([_, group]) => groupFilter(group, normalizedFilterState)), - flatMap(([rulesSource, group]) => group.rules.map((rule) => [rulesSource, group, rule] as const)), - filter(([_, __, rule]) => ruleFilter(rule, filterState)), - map(([rulesSource, group, rule]) => mapRuleToRuleWithOrigin(rulesSource, group, rule)) - ); + return promGroupsGenerator; + }); - return merge(grafanaIterator, dataSourcesIterator); + // Merge all generators + return { + iterable: merge(grafanaRulesGenerator, ...dataSourceGenerators), + abortController, + }; }; - return { getFilteredRulesIterator }; + return getFilteredRulesIterable; +} + +/** + * Finds all data sources that the user might want to filter by. + * Only allows Prometheus and Loki data source types. + */ +function getRulesSourcesFromFilter(filter: RulesFilter): DataSourceRulesSourceIdentifier[] { + return filter.dataSourceNames.reduce((acc, dataSourceName) => { + // since "getDatasourceAPIUid" can throw we'll omit any non-existing data sources + try { + const uid = getDatasourceAPIUid(dataSourceName); + const type = getDataSourceByUid(uid)?.type; + + if (type === undefined || isSupportedExternalRulesSourceType(type) === false) { + return acc; + } + + acc.push({ + name: dataSourceName, + uid, + ruleSourceType: 'datasource', + }); + } catch {} + + return acc; + }, []); } function mapRuleToRuleWithOrigin( @@ -127,70 +171,6 @@ function mapGrafanaRuleToRuleWithOrigin( }; } -/** - * Returns a new group with only the rules that match the filter. - * @returns A new group with filtered rules, or undefined if the group does not match the filter or all rules are filtered out. - */ -function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { - const { name, file } = group; - - // TODO Add fuzzy filtering or not - if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { - return false; - } - - if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { - return false; - } - - return true; -} - -function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { - const { name, labels = {}, health, type } = rule; - - const nameLower = name.toLowerCase(); - - if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { - return false; - } - - if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { - return false; - } - - if (filterState.labels.length > 0) { - const matchers = compact(filterState.labels.map(looseParseMatcher)); - const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); - if (!doRuleLabelsMatchQuery) { - return false; - } - } - - if (filterState.ruleType && type !== filterState.ruleType) { - return false; - } - - if (filterState.ruleState) { - if (!prometheusRuleType.alertingRule(rule)) { - return false; - } - if (rule.state !== filterState.ruleState) { - return false; - } - } - - if (filterState.ruleHealth && health !== filterState.ruleHealth) { - return false; - } - - if (filterState.dashboardUid) { - return rule.labels ? rule.labels[Annotation.dashboardUID] === filterState.dashboardUid : false; - } - - return true; -} - /** * Lowercase free form words, rule name, group name and namespace */ @@ -203,12 +183,3 @@ function normalizeFilterState(filterState: RulesFilter): RulesFilter { namespace: filterState.namespace?.toLowerCase(), }; } - -function looseParseMatcher(matcherQuery: string): Matcher | undefined { - try { - return parseMatcher(matcherQuery); - } catch { - // Try to createa a matcher than matches all values for a given key - return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; - } -} diff --git a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx index 81e269396f4..18da7186fb5 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx +++ b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx @@ -14,7 +14,7 @@ import { isLoading, useAsync } from '../../hooks/useAsync'; * @returns Pagination state and controls for navigating through rule groups */ export function usePaginatedPrometheusGroups( - groupsGenerator: AsyncGenerator, + groupsGenerator: AsyncIterator, pageSize: number ) { const [currentPage, setCurrentPage] = useState(1); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index c0eb608e2d1..13aa9982983 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -26,9 +26,12 @@ import { GrafanaAlertState, GrafanaAlertStateWithReason, GrafanaAlertingRuleDefinition, + GrafanaPromAlertingRuleDTO, + GrafanaPromRecordingRuleDTO, GrafanaRecordingRuleDefinition, PostableRuleDTO, PromAlertingRuleState, + PromRuleDTO, PromRuleType, RulerAlertingRuleDTO, RulerCloudRuleDTO, @@ -97,6 +100,14 @@ function isRecordingRule(rule?: Rule): rule is RecordingRule { return typeof rule === 'object' && rule.type === PromRuleType.Recording; } +function isGrafanaPromAlertingRule(rule?: Rule): rule is GrafanaPromAlertingRuleDTO { + return isAlertingRule(rule) && 'folderUid' in rule && 'uid' in rule; +} + +function isGrafanaPromRecordingRule(rule?: Rule): rule is GrafanaPromRecordingRuleDTO { + return isRecordingRule(rule) && 'folderUid' in rule && 'uid' in rule; +} + export const rulerRuleType = { grafana: { rule: isGrafanaRulerRule, @@ -118,6 +129,11 @@ export const prometheusRuleType = { rule: (rule?: Rule) => isAlertingRule(rule) || isRecordingRule(rule), alertingRule: isAlertingRule, recordingRule: isRecordingRule, + grafana: { + rule: (rule?: Rule) => isGrafanaPromAlertingRule(rule) || isGrafanaPromRecordingRule(rule), + alertingRule: isGrafanaPromAlertingRule, + recordingRule: isGrafanaPromRecordingRule, + }, }; export function alertInstanceKey(alert: Alert): string { @@ -212,7 +228,7 @@ export interface RulePluginOrigin { pluginId: string; } -export function getRulePluginOrigin(rule?: Rule | RulerRuleDTO): RulePluginOrigin | undefined { +export function getRulePluginOrigin(rule?: Rule | PromRuleDTO | RulerRuleDTO): RulePluginOrigin | undefined { if (!rule) { return undefined; } @@ -245,7 +261,7 @@ export function isPluginProvidedGroup(group: RulerRuleGroupDTO): boolean { return group.rules.some((rule) => isPluginProvidedRule(rule)); } -export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean { +export function isPluginProvidedRule(rule?: Rule | PromRuleDTO | RulerRuleDTO): boolean { return Boolean(getRulePluginOrigin(rule)); } @@ -277,7 +293,13 @@ export const flattenCombinedRules = (rules: CombinedRuleNamespace[]) => { groups.forEach(({ name: groupName, rules }) => { rules.forEach((rule) => { if (rule.promRule && isAlertingRule(rule.promRule)) { - acc.push({ dataSourceName: getRulesSourceName(rulesSource), namespaceName, groupName, ...rule }); + acc.push({ + dataSourceName: getRulesSourceName(rulesSource), + namespaceName, + groupName, + ...rule, + namespace: { ...rule.namespace, uid: rule.promRule.folderUid }, + }); } }); }); diff --git a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx index d730e70ebc4..727793d04d2 100644 --- a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx @@ -34,7 +34,7 @@ const mockFolderUid = '12345'; const random = Chance(1); const rule_uid = random.guid(); const mockRulerRulesResponse = getRulerRulesResponse(mockFolderName, mockFolderUid, rule_uid); -const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, rule_uid); +const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, mockFolderUid, rule_uid); describe('browse-dashboards BrowseFolderAlertingPage', () => { (useParams as jest.Mock).mockReturnValue({ uid: mockFolderUid }); diff --git a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts index fca05a8c63f..be447f4fa6c 100644 --- a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts @@ -2,8 +2,8 @@ import { Chance } from 'chance'; import { GrafanaAlertStateDecision, + GrafanaPromRulesResponse, PromAlertingRuleState, - PromRulesResponse, PromRuleType, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -57,7 +57,11 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, rul }; } -export function getPrometheusRulesResponse(folderName: string, rule_uid: string): PromRulesResponse { +export function getPrometheusRulesResponse( + folderName: string, + folderUid: string, + rule_uid: string +): GrafanaPromRulesResponse { const random = Chance(1); return { status: 'success', @@ -66,6 +70,7 @@ export function getPrometheusRulesResponse(folderName: string, rule_uid: string) { name: 'foo', file: folderName, + folderUid: folderUid, rules: [ { alerts: [], @@ -80,6 +85,7 @@ export function getPrometheusRulesResponse(folderName: string, rule_uid: string) lastEvaluation: '0001-01-01T00:00:00Z', evaluationTime: 0, uid: rule_uid, + folderUid: folderUid, }, ], interval: 60, diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 06673e54ee6..c03102bf873 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -17,7 +17,7 @@ import { } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; -import { isInCloneChain } from '../utils/clone'; +import { containsCloneKey, getOriginalKey, isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardOutline } from './DashboardOutline'; @@ -101,7 +101,9 @@ export class DashboardEditPane extends SceneObjectBase { return; } - const obj = sceneGraph.findByKey(this, element.id); + const elementId = containsCloneKey(element.id) ? getOriginalKey(element.id) : element.id; + + const obj = sceneGraph.findByKey(this, elementId); if (obj) { this.selectObject(obj, element.id, options); } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 3303244cb22..9e883816200 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -1,9 +1,9 @@ -// Libraries -import { useEffect } from 'react'; -import { useParams } from 'react-router-dom-v5-compat'; +import { useEffect, useRef } from 'react'; +import { Params, useParams } from 'react-router-dom-v5-compat'; import { usePrevious } from 'react-use'; import { PageLayoutType } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { UrlSyncContextProvider } from '@grafana/scenes'; import { Box } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -31,6 +31,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { const { dashboard, isLoading, loadError } = stateManager.useState(); // After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need const routeReloadCounter = (location.state as any)?.routeReloadCounter; + const prevParams = useRef>(params); useEffect(() => { if (route.routeName === DashboardRoutes.Normal && type === 'snapshot') { @@ -48,7 +49,31 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { return () => { stateManager.clearState(); }; - }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type, path]); + + // removing slug and path (which has slug in it) from dependencies to prevent unmount when data links reference + // the same dashboard with no slug in url + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, type]); + + useEffect(() => { + // This use effect corrects URL without refresh when navigating to the same dashboard + // using data link that has no slug in url + if (route.routeName === DashboardRoutes.Normal) { + // correct URL only when there are no new slug + // if slug is defined and incorrect it will be corrected in stateManager + if (uid === prevParams.current.uid && prevParams.current.slug && !slug) { + const correctedUrl = `/d/${uid}/${prevParams.current.slug}`; + locationService.replace({ + ...locationService.getLocation(), + pathname: correctedUrl, + }); + } + } + + return () => { + prevParams.current = { uid, slug: !slug ? prevParams.current.slug : slug }; + }; + }, [route, slug, type, uid]); if (!dashboard) { let errorElement; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 40f058b6d0e..e8ebbd08d02 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -1,6 +1,6 @@ import { advanceBy } from 'jest-date-mock'; -import { BackendSrv, locationService, setBackendSrv } from '@grafana/runtime'; +import { BackendSrv, config, locationService, setBackendSrv } from '@grafana/runtime'; import { Spec as DashboardV2Spec, defaultSpec as defaultDashboardV2Spec, @@ -26,6 +26,21 @@ import { DASHBOARD_CACHE_TTL, } from './DashboardScenePageStateManager'; +// Mock the config module +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + return { + ...original, + config: { + ...original.config, + featureToggles: { + ...original.config.featureToggles, + dashboardNewLayouts: false, // Default value + }, + }, + }; +}); + jest.mock('app/features/dashboard/api/dashboard_api', () => ({ getDashboardAPI: jest.fn(), })); @@ -776,6 +791,7 @@ describe('DashboardScenePageStateManager v2', () => { describe('UnifiedDashboardScenePageStateManager', () => { afterEach(() => { store.delete(DASHBOARD_FROM_LS_KEY); + config.featureToggles.dashboardNewLayouts = false; }); describe('when fetching/loading a dashboard', () => { @@ -987,6 +1003,86 @@ describe('UnifiedDashboardScenePageStateManager', () => { expect(loader.state.dashboard!.serializer.initialSaveModel).toEqual(customHomeDashboardV1Spec); }); }); + + describe('Provisioned dashboard', () => { + it('should load a provisioned v1 dashboard', async () => { + const loader = new UnifiedDashboardScenePageStateManager({}); + setBackendSrv({ + get: () => Promise.resolve(v1ProvisionedDashboardResource), + } as unknown as BackendSrv); + await loader.loadDashboard({ uid: 'blah-blah', route: DashboardRoutes.Provisioning }); + + expect(loader.state.dashboard).toBeDefined(); + expect(loader.state.dashboard!.serializer.initialSaveModel).toEqual( + v1ProvisionedDashboardResource.resource.dryRun.spec + ); + }); + + it('should load a provisioned v2 dashboard', async () => { + const loader = new UnifiedDashboardScenePageStateManager({}); + setBackendSrv({ + get: () => Promise.resolve(v2ProvisionedDashboardResource), + } as unknown as BackendSrv); + await loader.loadDashboard({ uid: 'blah-blah', route: DashboardRoutes.Provisioning }); + + expect(loader.state.dashboard).toBeDefined(); + expect(loader.state.dashboard!.serializer.initialSaveModel).toEqual( + v2ProvisionedDashboardResource.resource.dryRun.spec + ); + }); + }); + + describe('New dashboards', () => { + it('should use v1 manager for new dashboards when dashboardNewLayouts feature toggle is disabled', async () => { + config.featureToggles.dashboardNewLayouts = false; + + const manager = new UnifiedDashboardScenePageStateManager({}); + manager.setActiveManager('v2'); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + + await manager.loadDashboard({ uid: '', route: DashboardRoutes.New }); + + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + expect(manager.state.dashboard).toBeDefined(); + expect(manager.state.dashboard?.state.title).toBe('New dashboard'); + }); + + it('should use v2 manager for new dashboards when dashboardNewLayouts feature toggle is enabled', async () => { + config.featureToggles.dashboardNewLayouts = true; + + const manager = new UnifiedDashboardScenePageStateManager({}); + manager.setActiveManager('v1'); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + + await manager.loadDashboard({ uid: '', route: DashboardRoutes.New }); + + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + expect(manager.state.dashboard).toBeDefined(); + expect(manager.state.dashboard?.state.title).toBe('New dashboard'); + }); + + it('should maintain manager version for subsequent loads based on feature toggle', async () => { + config.featureToggles.dashboardNewLayouts = false; + const manager1 = new UnifiedDashboardScenePageStateManager({}); + manager1.setActiveManager('v2'); + await manager1.loadDashboard({ uid: '', route: DashboardRoutes.New }); + expect(manager1['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + + manager1.setActiveManager('v2'); + await manager1.loadDashboard({ uid: '', route: DashboardRoutes.New }); + expect(manager1['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + + config.featureToggles.dashboardNewLayouts = true; + const manager2 = new UnifiedDashboardScenePageStateManager({}); + manager2.setActiveManager('v1'); + await manager2.loadDashboard({ uid: '', route: DashboardRoutes.New }); + expect(manager2['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + + manager2.setActiveManager('v1'); + await manager2.loadDashboard({ uid: '', route: DashboardRoutes.New }); + expect(manager2['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + }); + }); }); const customHomeDashboardV1Spec = { @@ -1116,3 +1212,446 @@ const customHomeDashboardV2Spec = { }, }, }; + +const v1ProvisionedDashboardResource = { + kind: 'ResourceWrapper', + apiVersion: 'provisioning.grafana.app/v0alpha1', + path: 'new-dashboard.json', + ref: 'dashboard/2025-04-11-nkXIe', + hash: '28e6dd34e226ec27e19f9894270290fc105a77b0', + repository: { + type: 'github', + title: 'https://github.com/dprokop/grafana-git-sync-test', + namespace: 'default', + name: 'repository-643e5fb', + }, + urls: { + sourceURL: 'https://github.com/dprokop/grafana-git-sync-test/blob/dashboard/2025-04-11-nkXIe/new-dashboard.json', + repositoryURL: 'https://github.com/dprokop/grafana-git-sync-test', + newPullRequestURL: + 'https://github.com/dprokop/grafana-git-sync-test/compare/main...dashboard/2025-04-11-nkXIe?quick_pull=1&labels=grafana', + compareURL: 'https://github.com/dprokop/grafana-git-sync-test/compare/main...dashboard/2025-04-11-nkXIe', + }, + resource: { + type: { + group: 'dashboard.grafana.app', + version: 'v1alpha1', + kind: 'Dashboard', + resource: 'dashboards', + }, + file: {}, + existing: {}, + action: 'update', + dryRun: { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + annotations: { + 'grafana.app/managedBy': 'repo', + 'grafana.app/managerId': 'repository-643e5fb', + 'grafana.app/sourceChecksum': '28e6dd34e226ec27e19f9894270290fc105a77b0', + 'grafana.app/sourcePath': 'new-dashboard.json', + }, + creationTimestamp: '2025-04-09T07:27:46Z', + generation: 1, + managedFields: [ + { + apiVersion: 'dashboard.grafana.app/v1alpha1', + fieldsType: 'FieldsV1', + fieldsV1: { + 'f:metadata': { + 'f:annotations': { + '.': {}, + 'f:grafana.app/managedBy': {}, + 'f:grafana.app/managerId': {}, + 'f:grafana.app/sourceChecksum': {}, + 'f:grafana.app/sourcePath': {}, + }, + }, + 'f:spec': { + 'f:annotations': { + '.': {}, + 'f:list': {}, + }, + 'f:editable': {}, + 'f:fiscalYearStartMonth': {}, + 'f:graphTooltip': {}, + 'f:links': {}, + 'f:panels': {}, + 'f:preload': {}, + 'f:schemaVersion': {}, + 'f:tags': {}, + 'f:templating': { + '.': {}, + 'f:list': {}, + }, + 'f:time': { + '.': {}, + 'f:from': {}, + 'f:to': {}, + }, + 'f:timepicker': {}, + 'f:timezone': {}, + 'f:title': {}, + }, + }, + manager: 'grafana', + operation: 'Update', + time: '2025-04-11T10:35:05Z', + }, + ], + name: 'adsm7zf', + namespace: 'default', + resourceVersion: '1744183666927980', + uid: 'ef523e2b-1e66-4921-b3f9-e7a9b215c988', + }, + spec: { + 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, + links: [], + panels: [ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'PD8C576611E62080A', + }, + 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: 10, + x: 0, + y: 0, + }, + id: 1, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + hideZeros: false, + mode: 'single', + sort: 'none', + }, + }, + pluginVersion: '12.0.0-pre', + targets: [ + { + refId: 'A', + }, + ], + title: 'New panel', + type: 'timeseries', + }, + ], + preload: false, + schemaVersion: 41, + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: 'browser', + title: 'New dashboard', + }, + status: {}, + }, + upsert: null, + }, +}; + +const v2ProvisionedDashboardResource = { + kind: 'ResourceWrapper', + apiVersion: 'provisioning.grafana.app/v0alpha1', + path: 'v2dashboards/new-dashboard-2025-04-09-nTqgq.json', + ref: 'dashboard/2025-04-11-FzFKZ', + hash: '2d1c7981d4327f5c75afd920e910f1f82e9be706', + repository: { + type: 'github', + title: 'https://github.com/dprokop/grafana-git-sync-test', + namespace: 'default', + name: 'repository-643e5fb', + }, + urls: { + sourceURL: + 'https://github.com/dprokop/grafana-git-sync-test/blob/dashboard/2025-04-11-FzFKZ/v2dashboards/new-dashboard-2025-04-09-nTqgq.json', + repositoryURL: 'https://github.com/dprokop/grafana-git-sync-test', + newPullRequestURL: + 'https://github.com/dprokop/grafana-git-sync-test/compare/main...dashboard/2025-04-11-FzFKZ?quick_pull=1&labels=grafana', + compareURL: 'https://github.com/dprokop/grafana-git-sync-test/compare/main...dashboard/2025-04-11-FzFKZ', + }, + resource: { + type: { + group: 'dashboard.grafana.app', + version: 'v2alpha1', + kind: 'Dashboard', + resource: 'dashboards', + }, + file: {}, + existing: {}, + action: 'update', + dryRun: { + apiVersion: 'dashboard.grafana.app/v2alpha1', + kind: 'Dashboard', + metadata: { + annotations: { + 'grafana.app/folder': 'v2dashboards-8mdocprxtfyldpbpod3ayidiitt', + 'grafana.app/managedBy': 'repo', + 'grafana.app/managerId': 'repository-643e5fb', + 'grafana.app/sourceChecksum': '2d1c7981d4327f5c75afd920e910f1f82e9be706', + 'grafana.app/sourcePath': 'v2dashboards/new-dashboard-2025-04-09-nTqgq.json', + }, + creationTimestamp: '2025-04-09T12:11:20Z', + generation: 1, + name: 'dfeidsuico01kwc', + namespace: 'default', + resourceVersion: '1744200680060000', + uid: '47ed4201-a181-4d1c-b755-17548526d294', + }, + spec: { + annotations: [ + { + kind: 'AnnotationQuery', + spec: { + builtIn: true, + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + }, + }, + ], + cursorSync: 'Off', + description: '', + editable: true, + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'PD8C576611E62080A', + }, + hidden: false, + query: { + kind: 'grafana-testdata-datasource', + spec: { + scenarioId: 'random_walk', + seriesCount: 2, + }, + }, + refId: 'A', + }, + }, + ], + queryOptions: {}, + transformations: [], + }, + }, + description: '', + id: 1, + links: [], + title: 'New panel', + vizConfig: { + kind: 'timeseries', + spec: { + 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', + }, + }, + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: 0, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + hideZeros: false, + mode: 'single', + sort: 'none', + }, + }, + pluginVersion: '12.0.0-pre', + }, + }, + }, + }, + }, + layout: { + kind: 'AutoGridLayout', + spec: { + columnWidthMode: 'standard', + items: [ + { + kind: 'AutoGridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: 'panel-1', + }, + }, + }, + ], + maxColumnCount: 3, + rowHeightMode: 'short', + }, + }, + links: [], + liveNow: false, + preload: false, + tags: [], + timeSettings: { + autoRefresh: '', + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + fiscalYearStartMonth: 0, + from: 'now-6h', + hideTimepicker: false, + timezone: 'browser', + to: 'now', + }, + title: 'v2 test - auto grid', + variables: [], + }, + status: {}, + }, + upsert: null, + }, +}; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 33b8661681d..438c8d5cd73 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -4,10 +4,16 @@ import { locationUtil, UrlQueryMap } from '@grafana/data'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; +import { BASE_URL } from 'app/api/clients/provisioning/baseAPI'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; -import { AnnoKeyFolder } from 'app/features/apiserver/types'; +import { + AnnoKeyFolder, + AnnoKeyManagerIdentity, + AnnoKeyManagerKind, + AnnoKeySourcePath, +} from 'app/features/apiserver/types'; import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils'; @@ -15,6 +21,7 @@ import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; +import { ProvisioningPreview } from 'app/features/provisioning/types'; import { DashboardDataDTO, DashboardDTO, @@ -176,6 +183,84 @@ abstract class DashboardScenePageStateManagerBase } } + protected async loadProvisioningDashboard(repo: string, path: string): Promise { + const params = new URLSearchParams(window.location.search); + const ref = params.get('ref') ?? undefined; // commit hash or branch + + const url = `${BASE_URL}/repositories/${repo}/files/${path}`; + return getBackendSrv() + .get(url, ref ? { ref } : undefined) + .then((v) => { + // Load the results from dryRun + const dryRun = v.resource.dryRun; + if (!dryRun) { + return Promise.reject('failed to read provisioned dashboard'); + } + + if (!dryRun.apiVersion.startsWith('dashboard.grafana.app')) { + return Promise.reject('unexpected resource type: ' + dryRun.apiVersion); + } + + return this.processDashboardFromProvisioning(repo, path, dryRun, { + file: url, + ref: ref, + repo: repo, + }); + }); + } + + private processDashboardFromProvisioning( + repo: string, + path: string, + dryRun: any, + provisioningPreview: ProvisioningPreview + ) { + if (dryRun.apiVersion.split('/')[1] === 'v2alpha1') { + return { + ...dryRun, + kind: 'DashboardWithAccessInfo', + access: { + canStar: false, + isSnapshot: false, + canShare: false, + + // Should come from the repo settings + canDelete: true, + canSave: true, + canEdit: true, + }, + }; + } + + let anno = dryRun.metadata.annotations; + if (!anno) { + dryRun.metadata.annotations = {}; + } + anno[AnnoKeyManagerKind] = 'repo'; + anno[AnnoKeyManagerIdentity] = repo; + anno[AnnoKeySourcePath] = provisioningPreview.ref ? path + '#' + provisioningPreview.ref : path; + + return { + meta: { + canStar: false, + isSnapshot: false, + canShare: false, + + // Should come from the repo settings + canDelete: true, + canSave: true, + canEdit: true, + + // Includes additional k8s metadata + k8s: dryRun.metadata, + + // lookup info + provisioning: provisioningPreview, + }, + dashboard: dryRun.spec, + }; + } + public async loadDashboard(options: LoadDashboardOptions) { try { startMeasure(LOAD_SCENE_MEASUREMENT); @@ -230,15 +315,15 @@ abstract class DashboardScenePageStateManagerBase // Handling home dashboard flow separately from regular dashboard flow. if (options.route === DashboardRoutes.Home) { return await this.loadHomeDashboard(); - } else { - const rsp = await this.fetchDashboard(options); + } - if (!rsp) { - return null; - } + const rsp = await this.fetchDashboard(options); - return this.transformResponseToScene(rsp, options); + if (!rsp) { + return null; } + + return this.transformResponseToScene(rsp, options); } public getDashboardFromCache(cacheKey: string): T | null { @@ -356,9 +441,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag case DashboardRoutes.New: rsp = await buildNewDashboardSaveModel(urlFolderUid); break; - case DashboardRoutes.Provisioning: { - return await dashboardLoaderSrv.loadDashboard('provisioning', slug, uid); - } + case DashboardRoutes.Provisioning: + return this.loadProvisioningDashboard(slug || '', uid); case DashboardRoutes.Public: { return await dashboardLoaderSrv.loadDashboard('public', '', uid); } @@ -537,6 +621,9 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan case DashboardRoutes.New: rsp = await buildNewDashboardSaveModelV2(urlFolderUid); break; + case DashboardRoutes.Provisioning: { + return await this.loadProvisioningDashboard(slug || '', uid); + } case DashboardRoutes.Public: { return await this.dashboardLoader.loadDashboard('public', '', uid); } @@ -659,7 +746,6 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta this.v1Manager = new DashboardScenePageStateManager(initialState); this.v2Manager = new DashboardScenePageStateManagerV2(initialState); - // Start with v2 if newDashboardLayout is enabled, otherwise v1 this.activeManager = this.v1Manager; } @@ -704,7 +790,6 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta if (!rsp) { return null; } - if (isDashboardV2Resource(rsp)) { this.activeManager = this.v2Manager; return this.v2Manager.transformResponseToScene(rsp, options); @@ -752,8 +837,20 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta } public async loadDashboard(options: LoadDashboardOptions): Promise { + if (options.route === DashboardRoutes.New) { + const newDashboardVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1'; + this.setActiveManager(newDashboardVersion); + } return this.withVersionHandling((manager) => manager.loadDashboard.call(this, options)); } + + public setActiveManager(manager: 'v1' | 'v2') { + if (manager === 'v1') { + this.activeManager = this.v1Manager; + } else { + this.activeManager = this.v2Manager; + } + } } const managers: { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c7a1d1fec86..fb0d3eb0524 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -134,9 +134,6 @@ const promResponse: PromRulesResponse = { interval: 20, }, ], - totals: { - alerting: 2, - }, }, }; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 4c476384f8e..f376ede4a5f 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -39,7 +39,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { DashboardChangeInfo } from '../saving/shared'; -import { DashboardSceneSerializerLike, getDashboardSceneSerializer } from '../serialization/DashboardSceneSerializer'; +import { + DashboardSceneSerializerLike, + getDashboardSceneSerializer, + V2DashboardSerializer, +} from '../serialization/DashboardSceneSerializer'; import { serializeAutoGridItem } from '../serialization/layoutSerializers/AutoGridLayoutSerializer'; import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers/DefaultGridLayoutSerializer'; import { getElement } from '../serialization/layoutSerializers/utils'; @@ -767,8 +771,10 @@ export class DashboardScene extends SceneObjectBase impleme getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { const { meta } = this.state; const spec = this.getSaveAsModel(options); + + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2alpha1' : 'v1alpha1'; // get from the dashboard? return { - apiVersion: 'dashboard.grafana.app/v1alpha1', // get from the dashboard? + apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', metadata: { ...meta.k8s, diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index ad7440182c4..b81b34d2d1f 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -114,18 +114,30 @@ export class AutoGridLayoutManager } public duplicate(): DashboardLayoutManager { + const children = this.state.layout.state.children; + const clonedChildren: AutoGridItem[] = []; + + if (children.length) { + let panelId = dashboardSceneGraph.getNextPanelId(children[0].state.body); + + children.forEach((child) => { + const clone = child.clone({ + key: undefined, + body: child.state.body.clone({ + key: getVizPanelKeyForPanelId(panelId), + }), + }); + + clonedChildren.push(clone); + panelId++; + }); + } + return this.clone({ key: undefined, layout: this.state.layout.clone({ key: undefined, - children: this.state.layout.state.children.map((child) => - child.clone({ - key: undefined, - body: child.state.body.clone({ - key: getVizPanelKeyForPanelId(dashboardSceneGraph.getNextPanelId(child.state.body)), - }), - }) - ), + children: clonedChildren, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index caba4bcb6a3..e5b00abc627 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -229,22 +229,35 @@ export class DefaultGridLayoutManager } public duplicate(): DashboardLayoutManager { + const children = this.state.grid.state.children; + const hasGridItem = children.find((child) => child instanceof DashboardGridItem); + const clonedChildren: SceneGridItemLike[] = []; + + if (children.length) { + let panelId = hasGridItem ? dashboardSceneGraph.getNextPanelId(hasGridItem.state.body) : 1; + + children.forEach((child) => { + if (child instanceof DashboardGridItem) { + const clone = child.clone({ + key: undefined, + body: child.state.body.clone({ + key: getVizPanelKeyForPanelId(panelId), + }), + }); + + clonedChildren.push(clone); + panelId++; + } else { + clonedChildren.push(child.clone({ key: undefined })); + } + }); + } + const clone = this.clone({ key: undefined, grid: this.state.grid.clone({ key: undefined, - children: this.state.grid.state.children.map((child) => { - if (child instanceof DashboardGridItem) { - return child.clone({ - key: undefined, - body: child.state.body.clone({ - key: getVizPanelKeyForPanelId(dashboardSceneGraph.getNextPanelId(child.state.body)), - }), - }); - } - - return child.clone({ key: undefined }); - }), + children: clonedChildren, }), }); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 264eb8d236f..7cd516afd99 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -1,4 +1,11 @@ -import { SceneGridItemLike, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { + sceneGraph, + SceneGridItemLike, + SceneGridRow, + SceneObjectBase, + SceneObjectState, + VizPanel, +} from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { t } from 'app/core/internationalization'; @@ -8,12 +15,13 @@ import { ObjectsReorderedOnCanvasEvent, } from '../../edit-pane/shared'; import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; -import { isClonedKey } from '../../utils/clone'; +import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; +import { TabItemRepeaterBehavior } from '../layout-tabs/TabItemRepeaterBehavior'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { getRowFromClipboard } from '../layouts-shared/paste'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; @@ -81,7 +89,16 @@ export class RowsLayoutManager extends SceneObjectBase i } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - throw new Error('Method not implemented.'); + return this.clone({ + rows: this.state.rows.map((row) => { + const key = joinCloneKeys(ancestorKey, row.state.key!); + + return row.clone({ + key, + layout: row.state.layout.cloneLayout(key, isSource), + }); + }), + }); } public duplicate(): DashboardLayoutManager { @@ -179,7 +196,21 @@ export class RowsLayoutManager extends SceneObjectBase i if (layout instanceof TabsLayoutManager) { for (const tab of layout.state.tabs) { - rows.push(new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title })); + if (isClonedKey(tab.state.key!)) { + continue; + } + + const conditionalRendering = tab.state.conditionalRendering; + conditionalRendering?.clearParent(); + + const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior); + const $behaviors = !behavior + ? undefined + : [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })]; + + rows.push( + new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, $behaviors }) + ); } } else if (layout instanceof DefaultGridLayoutManager) { const config: Array<{ @@ -253,7 +284,7 @@ export class RowsLayoutManager extends SceneObjectBase i const duplicateTitles = new Set(); this.state.rows.forEach((row) => { - const title = row.state.title; + const title = sceneGraph.interpolate(row, row.state.title); const count = (titleCounts.get(title) ?? 0) + 1; titleCounts.set(title, count); if (count > 1 && title) { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 39e1de86928..17f033999b9 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -33,6 +33,7 @@ import { LayoutParent } from '../types/LayoutParent'; import { useEditOptions } from './TabItemEditor'; import { TabItemRenderer } from './TabItemRenderer'; +import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior'; import { TabItems } from './TabItems'; import { TabsLayoutManager } from './TabsLayoutManager'; @@ -176,6 +177,23 @@ export class TabItem this.onChangeTitle(name); } + public onChangeRepeat(repeat: string | undefined) { + let repeatBehavior = this._getRepeatBehavior(); + + if (repeat) { + // Remove repeat behavior if it exists to trigger repeat when adding new one + if (repeatBehavior) { + repeatBehavior.removeBehavior(); + } + + repeatBehavior = new TabItemRepeaterBehavior({ variableName: repeat }); + this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] }); + repeatBehavior.activate(); + } else { + repeatBehavior?.removeBehavior(); + } + } + public setIsDropTarget(isDropTarget: boolean) { if (!!this.state.isDropTarget !== isDropTarget) { this.setState({ isDropTarget }); @@ -199,6 +217,10 @@ export class TabItem } } + public getRepeatVariable(): string | undefined { + return this._getRepeatBehavior()?.state.variableName; + } + public getParentLayout(): TabsLayoutManager { return sceneGraph.getAncestor(this, TabsLayoutManager); } @@ -217,4 +239,8 @@ export class TabItem const duplicateTitles = parentLayout.duplicateTitles(); return !duplicateTitles.has(this.state.title); } + + private _getRepeatBehavior(): TabItemRepeaterBehavior | undefined { + return this.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior); + } } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 9958e0c87ce..84e7260de10 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -1,11 +1,16 @@ import { useMemo } from 'react'; -import { Input, Field } from '@grafana/ui'; -import { t } from 'app/core/internationalization'; +import { selectors } from '@grafana/e2e-selectors'; +import { Alert, Input, Field, TextLink } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor'; +import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { useEditPaneInputAutoFocus } from '../layouts-shared/utils'; @@ -25,9 +30,28 @@ export function useEditOptions(model: TabItem, isNewElement: boolean): OptionsPa [model, isNewElement] ); + const repeatCategory = useMemo( + () => + new OptionsPaneCategoryDescriptor({ + title: t('dashboard.tabs-layout.tab-options.repeat.title', 'Repeat options'), + id: 'repeat-options', + isOpenDefault: false, + }).addItem( + new OptionsPaneItemDescriptor({ + title: t('dashboard.tabs-layout.tab-options.repeat.variable.title', 'Repeat by variable'), + description: t( + 'dashboard.tabs-layout.tab-options.repeat.variable.description', + 'Repeat this tab for each value in the selected variable.' + ), + render: () => , + }) + ), + [model] + ); + const layoutCategory = useLayoutCategory(layout); - const editOptions = [tabCategory, ...layoutCategory]; + const editOptions = [tabCategory, ...layoutCategory, repeatCategory]; const conditionalRenderingCategory = useMemo( () => useConditionalRenderingEditor(model.state.conditionalRendering), @@ -62,3 +86,51 @@ function TabTitleInput({ tab, isNewElement }: { tab: TabItem; isNewElement: bool ); } + +function TabRepeatSelect({ tab }: { tab: TabItem }) { + const { layout } = tab.useState(); + const dashboard = useDashboard(tab); + + const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => { + const runner = getQueryRunnerFor(vizPanel); + return ( + runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY || + (runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME && + runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY)) + ); + }); + + return ( + <> + tab.onChangeRepeat(repeat)} + /> + {isAnyPanelUsingDashboardDS ? ( + +

+ + Panels in this tab use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel + in the original tab, not the ones in the repeated tabs. + +

+ + Learn more + +
+ ) : undefined} + + ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 2400ce19cfb..178a6870967 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -8,6 +8,7 @@ import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafa import { t } from 'app/core/internationalization'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; +import { useIsClone } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { TabItem } from './TabItem'; @@ -28,6 +29,9 @@ export function TabItemRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const pointerDistance = usePointerDistance(); const [isConditionallyHidden] = useIsConditionallyHidden(model); + const isClone = useIsClone(model); + + const isDraggable = !isClone && isEditing; if (isConditionallyHidden && !isEditing && !isActive) { return null; @@ -43,7 +47,7 @@ export function TabItemRenderer({ model }: SceneComponentProps) { } return ( - + {(dragProvided, dragSnapshot) => (
dragProvided.innerRef(ref)} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx new file mode 100644 index 00000000000..93a0d400a71 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx @@ -0,0 +1,272 @@ +import { VariableRefresh } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { + SceneGridRow, + SceneTimeRange, + SceneVariableSet, + TestVariable, + VariableValueOption, + PanelBuilders, +} from '@grafana/scenes'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { TextMode } from 'app/plugins/panel/text/panelcfg.gen'; + +import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone'; +import { activateFullSceneTree } from '../../utils/test-utils'; +import { DashboardScene } from '../DashboardScene'; +import { DashboardGridItem } from '../layout-default/DashboardGridItem'; +import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; + +import { TabItem } from './TabItem'; +import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior'; +import { TabsLayoutManager } from './TabsLayoutManager'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), +})); + +setPluginImportUtils({ + importPanelPlugin: () => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: () => undefined, +}); + +describe('TabItemRepeaterBehavior', () => { + describe('Given scene with variable with 5 values', () => { + let scene: DashboardScene, layout: TabsLayoutManager, repeatBehavior: TabItemRepeaterBehavior; + let layoutStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 })); + + layoutStateUpdates = []; + layout.subscribeToState((state) => layoutStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should repeat tab', () => { + // Verify that first tab still has repeat behavior + const tab1 = layout.state.tabs[0]; + expect(tab1.state.key).toBe(getCloneKey('tab-1', 0)); + expect(tab1.state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior); + expect(tab1.state.$variables!.state.variables[0].getValue()).toBe('A1'); + + const tab1Children = getTabChildren(tab1); + expect(tab1Children[0].state.key!).toBe(joinCloneKeys(tab1.state.key!, 'grid-item-0')); + expect(tab1Children[0].state.body?.state.key).toBe(joinCloneKeys(tab1Children[0].state.key!, 'panel-0')); + + const tab2 = layout.state.tabs[1]; + expect(tab2.state.key).toBe(getCloneKey('tab-1', 1)); + expect(tab2.state.$behaviors).toEqual([]); + expect(tab2.state.$variables!.state.variables[0].getValueText?.()).toBe('B'); + + const tab2Children = getTabChildren(tab2); + expect(tab2Children[0].state.key!).toBe(joinCloneKeys(tab2.state.key!, 'grid-item-0')); + expect(tab2Children[0].state.body?.state.key).toBe(joinCloneKeys(tab2Children[0].state.key!, 'panel-0')); + }); + + it('Repeated tabs should be read only', () => { + const tab1 = layout.state.tabs[0]; + expect(isInCloneChain(tab1.state.key!)).toBe(false); + + const tab2 = layout.state.tabs[1]; + expect(isInCloneChain(tab2.state.key!)).toBe(true); + }); + + it('Should push tab at the bottom down', () => { + // Should push tab at the bottom down + const tabAtTheBottom = layout.state.tabs[5]; + expect(tabAtTheBottom.state.title).toBe('Tab at the bottom'); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated tabs (and the panel above + the tab at the bottom) + expect(layout.state.tabs.length).toBe(3); + }); + + it('Should ignore repeat process if variable values are the same', async () => { + // trigger another repeat cycle by changing the variable + repeatBehavior.performRepeat(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(layoutStateUpdates.length).toBe(1); + }); + }); + + describe('Given scene with variable with 15 values', () => { + let scene: DashboardScene, layout: TabsLayoutManager; + let layoutStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, layout } = buildScene({ variableQueryTime: 0 }, [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + { label: 'F', value: 'F1' }, + { label: 'G', value: 'G1' }, + { label: 'H', value: 'H1' }, + { label: 'I', value: 'I1' }, + { label: 'J', value: 'J1' }, + { label: 'K', value: 'K1' }, + { label: 'L', value: 'L1' }, + { label: 'M', value: 'M1' }, + { label: 'N', value: 'N1' }, + { label: 'O', value: 'O1' }, + ])); + + layoutStateUpdates = []; + layout.subscribeToState((state) => layoutStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // should have 15 repeated tabs (and the panel above) + expect(layout.state.tabs.length).toBe(16); + + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated tabs (and the panel above) + expect(layout.state.tabs.length).toBe(3); + }); + }); + + describe('Given a scene with empty variable', () => { + it('Should preserve repeat tab', async () => { + const { scene, layout } = buildScene({ variableQueryTime: 0 }, []); + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + + // Should have 2 tabs, one without repeat and one with the dummy tab + expect(layout.state.tabs.length).toBe(2); + expect(layout.state.tabs[0].state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior); + }); + }); +}); + +interface SceneOptions { + variableQueryTime: number; + variableRefresh?: VariableRefresh; +} + +function buildTextPanel(key: string, content: string) { + const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build(); + panel.setState({ key }); + return panel; +} + +function buildScene( + options: SceneOptions, + variableOptions?: VariableValueOption[], + variableStateOverrides?: { isMulti: boolean } +) { + const repeatBehavior = new TabItemRepeaterBehavior({ variableName: 'server' }); + + const tabs = [ + new TabItem({ + key: 'tab-1', + $behaviors: [repeatBehavior], + layout: DefaultGridLayoutManager.fromGridItems([ + new DashboardGridItem({ + key: 'grid-item-1', + x: 0, + y: 11, + width: 24, + height: 5, + body: buildTextPanel('text-1', 'Panel inside repeated tab, server = $server'), + }), + ]), + }), + new TabItem({ + key: 'tab-2', + title: 'Tab at the bottom', + layout: DefaultGridLayoutManager.fromGridItems([ + new DashboardGridItem({ + key: 'grid-item-2', + x: 0, + y: 17, + body: buildTextPanel('text-2', 'Panel inside tab, server = $server'), + }), + new DashboardGridItem({ + key: 'grid-item-3', + x: 0, + y: 25, + body: buildTextPanel('text-3', 'Panel inside tab, server = $server'), + }), + ]), + }), + ]; + + const layout = new TabsLayoutManager({ tabs }); + + const scene = new DashboardScene({ + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'server', + query: 'A.*', + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + isMulti: true, + includeAll: true, + delayMs: options.variableQueryTime, + refresh: options.variableRefresh, + optionsToReturn: variableOptions ?? [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + ], + ...variableStateOverrides, + }), + ], + }), + body: layout, + }); + + const tabToRepeat = repeatBehavior.parent as SceneGridRow; + + return { scene, layout, tabs, repeatBehavior, tabToRepeat }; +} + +function getTabLayout(tab: TabItem): DefaultGridLayoutManager { + const layout = tab.getLayout(); + + if (!(layout instanceof DefaultGridLayoutManager)) { + throw new Error('Invalid layout'); + } + + return layout; +} + +function getTabChildren(tab: TabItem): DashboardGridItem[] { + const layout = getTabLayout(tab); + + const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem); + + if (filteredChildren.length !== layout.state.grid.state.children.length) { + throw new Error('Invalid layout'); + } + + return filteredChildren; +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts new file mode 100644 index 00000000000..a0e0207c7ad --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts @@ -0,0 +1,161 @@ +import { isEqual } from 'lodash'; + +import { + LocalValueVariable, + MultiValueVariable, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneVariableSet, + VariableDependencyConfig, + VariableValueSingle, +} from '@grafana/scenes'; + +import { isClonedKeyOf, getCloneKey } from '../../utils/clone'; +import { getMultiVariableValues } from '../../utils/utils'; +import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; + +import { TabItem } from './TabItem'; +import { TabsLayoutManager } from './TabsLayoutManager'; + +interface TabItemRepeaterBehaviorState extends SceneObjectState { + variableName: string; +} + +export class TabItemRepeaterBehavior extends SceneObjectBase { + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: [this.state.variableName], + onVariableUpdateCompleted: () => this.performRepeat(), + }); + + private _prevRepeatValues?: VariableValueSingle[]; + private _clonedTabs?: TabItem[]; + + public constructor(state: TabItemRepeaterBehaviorState) { + super(state); + + this.addActivationHandler(() => this._activationHandler()); + } + + private _activationHandler() { + this.performRepeat(); + } + + private _getTab(): TabItem { + if (!(this.parent instanceof TabItem)) { + throw new Error('RepeatedTabItemBehavior: Parent is not a TabItem'); + } + + return this.parent; + } + + private _getLayout(): TabsLayoutManager { + const layout = this._getTab().parent; + + if (!(layout instanceof TabsLayoutManager)) { + throw new Error('RepeatedTabItemBehavior: Layout is not a TabsLayoutManager'); + } + + return layout; + } + + public performRepeat(force = false) { + if (this._variableDependency.hasDependencyInLoadingState()) { + return; + } + + const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!); + + if (!variable) { + console.error('RepeatedTabItemBehavior: Variable not found'); + return; + } + + if (!(variable instanceof MultiValueVariable)) { + console.error('RepeatedTabItemBehavior: Variable is not a MultiValueVariable'); + return; + } + + const tabToRepeat = this._getTab(); + const layout = this._getLayout(); + const { values, texts } = getMultiVariableValues(variable); + + // Do nothing if values are the same + if (isEqual(this._prevRepeatValues, values) && !force) { + return; + } + + this._prevRepeatValues = values; + + this._clonedTabs = []; + + const tabContent = tabToRepeat.getLayout(); + + // when variable has no options (due to error or similar) it will not render any panels at all + // adding a placeholder in this case so that there is at least empty panel that can display error + const emptyVariablePlaceholderOption = { + values: [''], + texts: variable.hasAllValue() ? ['All'] : ['None'], + }; + + const variableValues = values.length ? values : emptyVariablePlaceholderOption.values; + const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts; + + // Loop through variable values and create repeats + for (let tabIndex = 0; tabIndex < variableValues.length; tabIndex++) { + const isSourceTab = tabIndex === 0; + const tabClone = isSourceTab ? tabToRepeat : tabToRepeat.clone({ $behaviors: [] }); + + const tabCloneKey = getCloneKey(tabToRepeat.state.key!, tabIndex); + + tabClone.setState({ + key: tabCloneKey, + $variables: new SceneVariableSet({ + variables: [ + new LocalValueVariable({ + name: this.state.variableName, + value: variableValues[tabIndex], + text: String(variableTexts[tabIndex]), + isMulti: variable.state.isMulti, + includeAll: variable.state.includeAll, + }), + ], + }), + layout: tabContent.cloneLayout?.(tabCloneKey, isSourceTab), + }); + + this._clonedTabs.push(tabClone); + } + + updateLayout(layout, this._clonedTabs, tabToRepeat.state.key!); + + // Used from dashboard url sync + this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); + } + + public removeBehavior() { + const tab = this._getTab(); + const layout = this._getLayout(); + const tabs = getTabsFilterOutRepeatClones(layout, tab.state.key!); + + layout.setState({ tabs }); + + // Remove behavior and the scoped local variable + tab.setState({ $behaviors: tab.state.$behaviors!.filter((b) => b !== this), $variables: undefined }); + } +} + +function updateLayout(layout: TabsLayoutManager, tabs: TabItem[], tabKey: string) { + const allTabs = getTabsFilterOutRepeatClones(layout, tabKey); + const index = allTabs.findIndex((tab) => tab.state.key!.includes(tabKey)); + + if (index === -1) { + throw new Error('TabItemRepeaterBehavior: Tab not found in layout'); + } + + layout.setState({ tabs: [...allTabs.slice(0, index), ...tabs, ...allTabs.slice(index + 1)] }); +} + +function getTabsFilterOutRepeatClones(layout: TabsLayoutManager, tabKey: string) { + return layout.state.tabs.filter((tab) => !isClonedKeyOf(tab.state.key!, tabKey)); +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 9be608411c1..ba0bcb661eb 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -1,4 +1,5 @@ import { + sceneGraph, SceneObjectBase, SceneObjectState, SceneObjectUrlSyncConfig, @@ -14,8 +15,10 @@ import { ObjectsReorderedOnCanvasEvent, } from '../../edit-pane/shared'; import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; +import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; import { RowItem } from '../layout-rows/RowItem'; +import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { getTabFromClipboard } from '../layouts-shared/paste'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; @@ -23,6 +26,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { TabItem } from './TabItem'; +import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior'; import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer'; interface TabsLayoutManagerState extends SceneObjectState { @@ -122,7 +126,16 @@ export class TabsLayoutManager extends SceneObjectBase i } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - throw new Error('Method not implemented.'); + return this.clone({ + tabs: this.state.tabs.map((tab) => { + const key = joinCloneKeys(ancestorKey, tab.state.key!); + + return tab.clone({ + key, + layout: tab.state.layout.cloneLayout(key, isSource), + }); + }), + }); } public addNewTab(tab?: TabItem) { @@ -149,7 +162,19 @@ export class TabsLayoutManager extends SceneObjectBase i } public activateRepeaters() { - this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.()); + this.state.tabs.forEach((tab) => { + if (!tab.isActive) { + tab.activate(); + } + + const behavior = (tab.state.$behaviors ?? []).find((b) => b instanceof TabItemRepeaterBehavior); + + if (!behavior?.isActive) { + behavior?.activate(); + } + + tab.getLayout().activateRepeaters?.(); + }); } public shouldUngroup(): boolean { @@ -210,7 +235,21 @@ export class TabsLayoutManager extends SceneObjectBase i if (layout instanceof RowsLayoutManager) { for (const row of layout.state.rows) { - tabs.push(new TabItem({ layout: row.state.layout.clone(), title: row.state.title })); + if (isClonedKey(row.state.key!)) { + continue; + } + + const conditionalRendering = row.state.conditionalRendering; + conditionalRendering?.clearParent(); + + const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior); + const $behaviors = !behavior + ? undefined + : [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })]; + + tabs.push( + new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors }) + ); } } else { layout.clearParent(); @@ -245,7 +284,7 @@ export class TabsLayoutManager extends SceneObjectBase i const duplicateTitles = new Set(); this.state.tabs.forEach((tab) => { - const title = tab.state.title; + const title = sceneGraph.interpolate(tab, tab.state.title); const count = (titleCounts.get(title) ?? 0) + 1; titleCounts.set(title, count); if (count > 1) { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx index cbf84c9fd1d..291fa71d981 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx @@ -67,12 +67,20 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps -
+ {isEditing && ( +
+ + {currentTab && } + + {conditionalRenderingOverlay} +
+ )} + + {!isEditing && ( {currentTab && } - {isEditing && conditionalRenderingOverlay} -
+ )}
); } diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 69484792891..084121cc917 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -1078,6 +1078,51 @@ describe('DashboardSceneSerializer', () => { serializer.initializeDSReferencesMapping({ elements: {} } as DashboardV2Spec); expect(serializer.getDSReferencesMapping().panels.size).toBe(0); }); + + it('should initialize datasource references mapping when annotations dont have datasources', () => { + const saveModel: DashboardV2Spec = { + ...defaultDashboardV2Spec(), + title: 'Dashboard with annotations without datasource', + annotations: [ + { + kind: 'AnnotationQuery', + spec: { + name: 'Annotation 1', + query: { kind: 'prometheus', spec: {} }, + enable: true, + hide: false, + iconColor: 'red', + }, + }, + ], + }; + + serializer.initializeDSReferencesMapping(saveModel); + + const dsReferencesMap = serializer.getDSReferencesMapping(); + + // Annotation 1 should have no datasource + expect(dsReferencesMap.annotations.has('Annotation 1')).toBe(true); + }); + + it('should return early if the saveModel is not a V2 dashboard', () => { + const v1SaveModel: Dashboard = { + title: 'Test Dashboard', + uid: 'my-uid', + schemaVersion: 30, + panels: [ + { id: 1, title: 'Panel 1', type: 'text' }, + { id: 2, title: 'Panel 2', type: 'text' }, + ], + }; + serializer.initializeDSReferencesMapping(v1SaveModel as unknown as DashboardV2Spec); + expect(serializer.getDSReferencesMapping()).toEqual({ + panels: new Map(), + variables: new Set(), + annotations: new Set(), + }); + expect(serializer.getDSReferencesMapping().panels.size).toBe(0); + }); }); describe('V1DashboardSerializer', () => { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index edf0fa2125c..e6f29dc9d68 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -2,6 +2,7 @@ import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; import { @@ -235,6 +236,12 @@ export class V2DashboardSerializer } initializeDSReferencesMapping(saveModel: DashboardV2Spec | undefined) { + // The saveModel could be undefined or not a DashboardV2Spec + // when dashboardsNewLayout is enabled, saveModel could be v1 + // in those cases, only when saving we will convert to v2 + if (saveModel === undefined || (saveModel && !isDashboardV2Spec(saveModel))) { + return; + } // initialize the object this.defaultDsReferencesMap = { panels: new Map>(), @@ -274,6 +281,15 @@ export class V2DashboardSerializer } } } + + // initialize annotations ds references map + if (saveModel?.annotations) { + for (const annotation of saveModel.annotations) { + if (!annotation.spec.datasource) { + this.defaultDsReferencesMap.annotations.add(annotation.spec.name); + } + } + } } getDSReferencesMapping() { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 41482314925..1f802147429 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -35,10 +35,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "kind": "AnnotationQuery", "spec": { "builtIn": false, - "datasource": { - "type": "loki", - "uid": "Loki", - }, "enable": true, "hide": true, "iconColor": "green", diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index b6fae840629..49a8cf94870 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -1,4 +1,3 @@ -import { SceneObject } from '@grafana/scenes'; import { Spec as DashboardV2Spec, RowsLayoutRowKind, @@ -7,6 +6,7 @@ import { import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; +import { isClonedKey } from '../../utils/clone'; import { layoutDeserializerRegistry } from './layoutSerializerRegistry'; import { getConditionalRendering } from './utils'; @@ -15,7 +15,7 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard return { kind: 'RowsLayout', spec: { - rows: layoutManager.state.rows.map(serializeRow), + rows: layoutManager.state.rows.filter((row) => !isClonedKey(row.state.key!)).map(serializeRow), }, }; } @@ -72,17 +72,16 @@ export function deserializeRow( panelIdGenerator?: () => number ): RowItem { const layout = row.spec.layout; - const behaviors: SceneObject[] = []; - if (row.spec.repeat) { - behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })); - } + const $behaviors = !row.spec.repeat + ? undefined + : [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })]; return new RowItem({ title: row.spec.title, collapse: row.spec.collapse, hideHeader: row.spec.hideHeader, fillScreen: row.spec.fillScreen, - $behaviors: behaviors, + $behaviors, layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), conditionalRendering: getConditionalRendering(row), }); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index 6ab820816d1..9a8cbe50bdb 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -4,7 +4,9 @@ import { } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { TabItem } from '../../scene/layout-tabs/TabItem'; +import { TabItemRepeaterBehavior } from '../../scene/layout-tabs/TabItemRepeaterBehavior'; import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; +import { isClonedKey } from '../../utils/clone'; import { layoutDeserializerRegistry } from './layoutSerializerRegistry'; import { getConditionalRendering } from './utils'; @@ -13,7 +15,7 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard return { kind: 'TabsLayout', spec: { - tabs: layoutManager.state.tabs.map(serializeTab), + tabs: layoutManager.state.tabs.filter((tab) => !isClonedKey(tab.state.key!)).map(serializeTab), }, }; } @@ -34,6 +36,17 @@ export function serializeTab(tab: TabItem): TabsLayoutTabKind { tabKind.spec.conditionalRendering = conditionalRenderingRootGroup; } + if (tab.state.$behaviors) { + for (const behavior of tab.state.$behaviors) { + if (behavior instanceof TabItemRepeaterBehavior) { + if (tabKind.spec.repeat) { + throw new Error('Multiple repeaters are not supported'); + } + tabKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' }; + } + } + } + return tabKind; } @@ -61,9 +74,14 @@ export function deserializeTab( panelIdGenerator?: () => number ): TabItem { const layout = tab.spec.layout; + const $behaviors = !tab.spec.repeat + ? undefined + : [new TabItemRepeaterBehavior({ variableName: tab.spec.repeat.value })]; + return new TabItem({ title: tab.spec.title, layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), + $behaviors, conditionalRendering: getConditionalRendering(tab), }); } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts new file mode 100644 index 00000000000..050947de00d --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -0,0 +1,132 @@ +import { PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; + +import { getRuntimePanelDataSource } from './utils'; + +// Mock the config needed for the function +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + bootData: { + settings: { + defaultDatasource: 'default-ds-grafana', + datasources: { + 'default-ds-grafana': { + uid: 'default-ds-uid', + name: 'Default DS', + meta: { id: 'default-ds-grafana' }, + type: 'datasource', + }, + prometheus: { + uid: 'prometheus-uid', + name: 'Prometheus', + meta: { id: 'prometheus' }, + type: 'datasource', + }, + loki: { + uid: 'loki-uid', + name: 'Loki', + meta: { id: 'loki' }, + type: 'datasource', + }, + }, + }, + }, + }, +})); + +describe('getRuntimePanelDataSource', () => { + it('should return the datasource when it is specified in the query', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: { + uid: 'test-ds-uid', + type: 'test-ds-type', + }, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'test-ds-uid', + type: 'test-ds-type', + }); + }); + + it('should infer datasource based on query kind when datasource is not specified', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: undefined, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'prometheus-uid', + type: 'prometheus', + }); + }); + + it('should use default datasource when no datasource is specified and query kind does not match any available datasource', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: undefined, + query: { + kind: 'unknown-type', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'default-ds-uid', + type: 'default-ds-grafana', + }); + }); + + it('should handle the case when datasource uid is empty string', () => { + const query: PanelQueryKind = { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + datasource: { + uid: '', + type: 'test-ds-type', + }, + query: { + kind: 'prometheus', + spec: {}, + }, + }, + }; + + const result = getRuntimePanelDataSource(query); + + expect(result).toEqual({ + uid: 'prometheus-uid', + type: 'prometheus', + }); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 81c3985f238..c3b1133c1ec 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -67,6 +67,7 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { displayMode: panel.spec.transparent ? 'transparent' : 'default', hoverHeader: !panel.spec.title && !timeOverrideShown, hoverHeaderOffset: 0, + seriesLimit: config.panelSeriesLimit, $data: createPanelDataProvider(panel), titleItems, $behaviors: [], @@ -106,6 +107,7 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane const vizPanelState: VizPanelState = { key: getVizPanelKeyForPanelId(id ?? panel.spec.id), titleItems, + seriesLimit: config.panelSeriesLimit, $behaviors: [ new LibraryPanelBehavior({ uid: panel.spec.libraryPanel.uid, @@ -184,11 +186,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { panel.spec.data.spec.queries.forEach((query) => { if (!datasource) { if (!query.spec.datasource?.uid) { - const defaultDatasource = config.bootData.settings.defaultDatasource; - const dsList = config.bootData.settings.datasources; - // this is look up by type - const bestGuess = Object.values(dsList).find((ds) => ds.meta.id === query.spec.query.kind); - datasource = bestGuess ? { uid: bestGuess.uid, type: bestGuess.meta.id } : dsList[defaultDatasource]; + datasource = getRuntimePanelDataSource(query); } else { datasource = query.spec.datasource; } @@ -201,26 +199,53 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { } export function getRuntimeVariableDataSource(variable: QueryVariableKind): DataSourceRef | undefined { - let datasource: DataSourceRef | undefined = undefined; + return getDataSourceForQuery(variable.spec.datasource, variable.spec.query.kind); +} - if (!datasource) { - if (!variable.spec.datasource?.uid) { - const defaultDatasource = config.bootData.settings.defaultDatasource; - const dsList = config.bootData.settings.datasources; - // this is look up by type - const bestGuess = Object.values(dsList).find((ds) => ds.meta.id === variable.spec.query.kind); - datasource = bestGuess ? { uid: bestGuess.uid, type: bestGuess.meta.id } : dsList[defaultDatasource]; - } else { - datasource = variable.spec.datasource; - } +export function getRuntimePanelDataSource(query: PanelQueryKind): DataSourceRef | undefined { + return getDataSourceForQuery(query.spec.datasource, query.spec.query.kind); +} + +/** + * @param querySpecDS - The datasource specified in the query + * @param queryKind - The kind of query being performed + * @returns The resolved DataSourceRef + */ +function getDataSourceForQuery( + querySpecDS: DataSourceRef | undefined | null, + queryKind: string +): DataSourceRef | undefined { + // If datasource is specified and has a uid, use it + if (querySpecDS?.uid) { + return querySpecDS; + } + + // Otherwise try to infer datasource based on query kind (kind = ds type) + const defaultDatasource = config.bootData.settings.defaultDatasource; + const dsList = config.bootData.settings.datasources; + + // Look up by query type/kind + const bestGuess = dsList && Object.values(dsList).find((ds) => ds.meta.id === queryKind); + + if (bestGuess) { + return { uid: bestGuess.uid, type: bestGuess.meta.id }; + } else if (dsList && dsList[defaultDatasource]) { + // In the datasource list from bootData "id" is the type and the uid could be uid or the name + // in cases like grafana, dashboard or mixed datasource + return { + uid: dsList[defaultDatasource].uid || dsList[defaultDatasource].name, + type: dsList[defaultDatasource].meta.id, + }; } - return datasource; + + // If we don't find a default datasource, return undefined + return undefined; } function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { return { refId: query.spec.refId, - datasource: query.spec.datasource, + datasource: getRuntimePanelDataSource(query), hide: query.spec.hidden, ...query.spec.query.spec, }; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 0b7435c1fe9..d0418ba94d0 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -322,6 +322,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { options: panel.options ?? {}, fieldConfig: panel.fieldConfig, pluginVersion: panel.pluginVersion, + seriesLimit: config.panelSeriesLimit, displayMode: panel.transparent ? 'transparent' : undefined, // To be replaced with it's own option persited option instead derived hoverHeader: !panel.title && !timeOverrideShown, diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 7417d108d63..727964416e3 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -20,6 +20,7 @@ import { SceneDataQuery, SceneQueryRunner, sceneUtils, + dataLayers, } from '@grafana/scenes'; import { DashboardCursorSync as DashboardCursorSyncV1, @@ -57,6 +58,7 @@ import { transformSceneToSaveModelSchemaV2, validateDashboardSchemaV2, getDataQueryKind, + getAutoAssignedDSRef, } from './transformSceneToSaveModelSchemaV2'; // Mock dependencies @@ -421,8 +423,8 @@ describe('transformSceneToSaveModelSchemaV2', () => { // Check that the annotation layers are correctly transformed expect(result.annotations).toHaveLength(3); - // check annotation layer 3 with no datasource has the default datasource defined as type - expect(result.annotations?.[2].spec.datasource?.type).toBe('loki'); + // Check annotation layer 3 without initial data source isn't updated with runtime default + expect(result.annotations?.[2].spec.datasource?.type).toBe(undefined); }); it('should transform the minimum scene to save model schema v2', () => { @@ -748,6 +750,58 @@ describe('getElementDatasource', () => { expect(result).toBeUndefined(); }); + it('should handle annotation datasources correctly', () => { + // Use the dataLayers.AnnotationsDataLayer directly + const annotationLayer = new dataLayers.AnnotationsDataLayer({ + key: 'annotation-1', + name: 'Test Annotation', + isEnabled: true, + isHidden: false, + query: { + name: 'Test Annotation', + enable: true, + hide: false, + iconColor: 'red', + datasource: { uid: 'prometheus', type: 'prometheus' }, + }, + }); + + // Create an annotation query without datasource + const annotationWithoutDS = { + name: 'No DS Annotation', + enable: true, + hide: false, + iconColor: 'blue', + }; + + // Mock dsReferencesMapping + const dsReferencesMapping = { + panels: new Map([['panel-1', new Set(['A'])]]), + variables: new Set(), + annotations: new Set(['No DS Annotation']), + }; + + // Test with annotation that has datasource defined + const resultWithDS = getElementDatasource( + annotationLayer, + annotationLayer.state.query, + 'annotation', + undefined, + dsReferencesMapping + ); + expect(resultWithDS).toEqual({ uid: 'prometheus', type: 'prometheus' }); + + // Test with annotation that has no datasource defined + const resultWithoutDS = getElementDatasource( + annotationLayer, + annotationWithoutDS, + 'annotation', + undefined, + dsReferencesMapping + ); + expect(resultWithoutDS).toBeUndefined(); + }); + it('should handle invalid input combinations', () => { const vizPanel = new VizPanel({ key: 'panel-1', @@ -772,6 +826,24 @@ describe('getElementDatasource', () => { // Variable set with query expect(getElementDatasource(variableSet, query, 'variable')).toBeUndefined(); }); + + it('should throw error when invalid type is passed to getAutoAssignedDSRef', () => { + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + }); + + const dsReferencesMapping = { + panels: new Map([['panel-1', new Set(['A'])]]), + variables: new Set(), + annotations: new Set(), + }; + + expect(() => { + // @ts-expect-error - intentionally passing invalid type to test error handling + getAutoAssignedDSRef(vizPanel, 'invalid-type', dsReferencesMapping); + }).toThrow('Invalid type invalid-type for getAutoAssignedDSRef'); + }); }); function getMinimalSceneState(body: DashboardLayoutManager): Partial { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 435129e10fe..6dfd5a1c157 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -12,7 +12,6 @@ import { SceneVariables, SceneVariableSet, VizPanel, - sceneUtils, } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; @@ -107,7 +106,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps // EOF elements // annotations - annotations: getAnnotations(sceneDash), + annotations: getAnnotations(sceneDash, dsReferencesMapping), // EOF annotations // layout @@ -392,7 +391,7 @@ function getVariables(oldDash: DashboardSceneState, dsReferencesMapping?: DSRefe return variables; } -function getAnnotations(state: DashboardSceneState): AnnotationQueryKind[] { +function getAnnotations(state: DashboardSceneState, dsReferencesMapping?: DSReferencesMapping): AnnotationQueryKind[] { const data = state.$data; if (!(data instanceof DashboardDataLayerSet)) { return []; @@ -407,7 +406,7 @@ function getAnnotations(state: DashboardSceneState): AnnotationQueryKind[] { spec: { builtIn: Boolean(layer.state.query.builtIn), name: layer.state.query.name, - datasource: layer.state.query.datasource || getDefaultDataSourceRef(), + datasource: getElementDatasource(layer, layer.state.query, 'annotation', undefined, dsReferencesMapping), enable: Boolean(layer.state.isEnabled), hide: Boolean(layer.state.isHidden), iconColor: layer.state.query.iconColor, @@ -657,9 +656,9 @@ function validateRowsLayout(layout: unknown) { } } -function getAutoAssignedDSRef( - element: VizPanel | SceneVariables, - type: 'panels' | 'variables', +export function getAutoAssignedDSRef( + element: VizPanel | SceneVariables | dataLayers.AnnotationsDataLayer, + type: 'panels' | 'variables' | 'annotations', elementMapReferences?: DSReferencesMapping ): Set { if (!elementMapReferences) { @@ -670,16 +669,25 @@ function getAutoAssignedDSRef( return elementMapReferences.panels.get(elementKey) || new Set(); } - return elementMapReferences.variables; + if (type === 'variables') { + return elementMapReferences.variables; + } + + if (type === 'annotations') { + return elementMapReferences.annotations; + } + + // if type is not panels, annotations, or variables, throw error + throw new Error(`Invalid type ${type} for getAutoAssignedDSRef`); } /** * Determines if a data source reference should be persisted for a query or variable */ -export function getPersistedDSFor( +export function getPersistedDSFor( element: T, autoAssignedDsRef: Set, - type: 'query' | 'variable', + type: 'query' | 'variable' | 'annotation', context?: SceneQueryRunner ): DataSourceRef | undefined { // Get the element identifier - refId for queries, name for variables @@ -705,6 +713,10 @@ export function getPersistedDSFor( return element.state.datasource || {}; } + if (type === 'annotation' && 'datasource' in element) { + return element.datasource || {}; + } + return undefined; } @@ -713,32 +725,52 @@ export function getPersistedDSFor( * @returns refId for queries, name for variables * TODO: we will add annotations in the future */ -function getElementIdentifier( +function getElementIdentifier( element: T, - type: 'query' | 'variable' + type: 'query' | 'variable' | 'annotation' ): string { // when is type query look for refId if (type === 'query') { return 'refId' in element ? element.refId : ''; } - // when is type variable look for the name of the variable - return 'state' in element && 'name' in element.state ? element.state.name : ''; + + if (type === 'variable') { + // when is type variable look for the name of the variable + return 'state' in element && 'name' in element.state ? element.state.name : ''; + } + + // when is type annotation look for annotation name + if (type === 'annotation') { + return 'name' in element ? element.name : ''; + } + + throw new Error(`Invalid type ${type} for getElementIdentifier`); } -function isVizPanel(element: VizPanel | SceneVariables): element is VizPanel { +function isVizPanel(element: VizPanel | SceneVariables | dataLayers.AnnotationsDataLayer): element is VizPanel { // FIXME: is there another way to do this? return 'pluginId' in element.state; } -function isSceneVariables(element: VizPanel | SceneVariables): element is SceneVariables { +function isSceneVariables( + element: VizPanel | SceneVariables | dataLayers.AnnotationsDataLayer +): element is SceneVariables { // Check for properties unique to SceneVariables but not in VizPanel return !('pluginId' in element.state) && ('variables' in element.state || 'getValue' in element); } -function isSceneDataQuery(query: SceneDataQuery | QueryVariable): query is SceneDataQuery { +function isSceneDataQuery(query: SceneDataQuery | QueryVariable | AnnotationQuery): query is SceneDataQuery { return 'refId' in query && !('state' in query); } +function isAnnotationQuery(query: SceneDataQuery | QueryVariable | AnnotationQuery): query is AnnotationQuery { + return 'datasource' in query && 'name' in query; +} + +function isQueryVariable(query: SceneDataQuery | QueryVariable | AnnotationQuery): query is QueryVariable { + return 'state' in query && 'name' in query.state; +} + /** * Get the persisted datasource for a query or variable * When a query or variable is created it could not have a datasource set @@ -747,35 +779,41 @@ function isSceneDataQuery(query: SceneDataQuery | QueryVariable): query is Scene * */ export function getElementDatasource( - element: VizPanel | SceneVariables, - queryElement: SceneDataQuery | QueryVariable, - type: 'panel' | 'variable', + element: VizPanel | SceneVariables | dataLayers.AnnotationsDataLayer, + queryElement: SceneDataQuery | QueryVariable | AnnotationQuery, + type: 'panel' | 'variable' | 'annotation', queryRunner?: SceneQueryRunner, dsReferencesMapping?: DSReferencesMapping ): DataSourceRef | undefined { + let result: DataSourceRef | undefined; if (type === 'panel') { if (!queryRunner || !isVizPanel(element) || !isSceneDataQuery(queryElement)) { return undefined; } // Get datasource for panel query const autoAssignedRefs = getAutoAssignedDSRef(element, 'panels', dsReferencesMapping); - return getPersistedDSFor(queryElement, autoAssignedRefs, 'query', queryRunner); + result = getPersistedDSFor(queryElement, autoAssignedRefs, 'query', queryRunner); } if (type === 'variable') { - if (!isSceneVariables(element) || isSceneDataQuery(queryElement)) { + if (!isSceneVariables(element) || !isQueryVariable(queryElement)) { return undefined; } // Get datasource for variable - if (!sceneUtils.isQueryVariable(queryElement)) { - return undefined; - } const autoAssignedRefs = getAutoAssignedDSRef(element, 'variables', dsReferencesMapping); - // Important: Only return the datasource if it's not in auto-assigned refs - // and if the result would not be an empty object - const result = getPersistedDSFor(queryElement, autoAssignedRefs, 'variable'); - return Object.keys(result || {}).length > 0 ? result : undefined; + + result = getPersistedDSFor(queryElement, autoAssignedRefs, 'variable'); } - return undefined; + if (type === 'annotation') { + if (!isAnnotationQuery(queryElement)) { + return undefined; + } + // Get datasource for annotation + const autoAssignedRefs = getAutoAssignedDSRef(element, 'annotations', dsReferencesMapping); + result = getPersistedDSFor(queryElement, autoAssignedRefs, 'annotation'); + } + // Important: Only return the datasource if it's not in auto-assigned refs + // and if the result would not be an empty object + return Object.keys(result || {}).length > 0 ? result : undefined; } diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index fff901adb67..e4e302dd652 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -328,6 +328,7 @@ export function getDefaultVizPanel(): VizPanel { return new VizPanel({ title: newPanelTitle, pluginId: defaultPluginId, + seriesLimit: config.panelSeriesLimit, titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], hoverHeaderOffset: 0, $behaviors: [], diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index d788365f4d5..c3629145e15 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -13,7 +13,6 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardDTO } from 'app/types'; import { appEvents } from '../../../core/core'; -import { loadDashboardFromProvisioning } from '../../provisioning/dashboardLoader'; import { ResponseTransformers } from '../api/ResponseTransformers'; import { getDashboardAPI } from '../api/dashboard_api'; import { DashboardVersionError, DashboardWithAccessInfo } from '../api/types'; @@ -37,6 +36,7 @@ abstract class DashboardLoaderSrvBase implements DashboardLoaderSrvLike { uid: string | undefined, params?: UrlQueryMap ): Promise; + abstract loadSnapshot(slug: string): Promise; protected loadScriptedDashboard(file: string) { @@ -124,10 +124,6 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase { if (type === 'script' && slug) { promise = this.loadScriptedDashboard(slug); - } else if (type === 'provisioning' && uid && slug) { - promise = loadDashboardFromProvisioning(slug, uid); - // needed for the old architecture - // in scenes this is handled through loadSnapshot method } else if (type === 'snapshot' && slug) { promise = getDashboardSnapshotSrv().getSnapshot(slug); } else if (type === 'public' && uid) { @@ -200,8 +196,6 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase { return ResponseTransformers.ensureV2Response(result); }); - } else if (type === 'provisioning' && uid && slug) { - promise = loadDashboardFromProvisioning(slug, uid).then((r) => ResponseTransformers.ensureV2Response(r)); } else if (uid) { if (!params) { const cachedDashboard = stateManager.getDashboardFromCache(uid); @@ -263,5 +257,6 @@ export const setDashboardLoaderSrv = (srv: DashboardLoaderSrv) => { if (process.env.NODE_ENV !== 'test') { throw new Error('dashboardLoaderSrv can be only overriden in test environment'); } + dashboardLoaderSrv = srv; }; diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 780cdca848e..73eeea1252a 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -14,39 +14,19 @@ import { Stack, Switch, } from '@grafana/ui'; -import { Repository, RepositorySpec } from 'app/api/clients/provisioning'; +import { Repository } from 'app/api/clients/provisioning'; import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { t, Trans } from 'app/core/internationalization'; import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { RepositoryFormData } from '../types'; -import { dataToSpec, specToData } from '../utils/data'; +import { dataToSpec } from '../utils/data'; import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse'; +import { getDefaultValues } from './defaults'; -export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { - if (!repository) { - return { - type: 'github', - title: 'Repository', - token: '', - url: '', - branch: 'main', - generateDashboardPreviews: false, - readOnly: false, - prWorkflow: true, - path: 'grafana/', - sync: { - enabled: false, - target: 'instance', - intervalSeconds: 60, - }, - }; - } - return specToData(repository); -} - +// This needs to be a function for translations to work const getOptions = () => { const typeOptions = [ { value: 'github', label: t('provisioning.config-form.option-github', 'GitHub') }, @@ -254,11 +234,7 @@ export function ConfigForm({ data }: ConfigFormProps) { } /> - {type === 'github' && ( - } - /> - )} + {type === 'github' && } ; } -export function ConfigFormGithubCollapse({ previews }: ConfigFormGithubCollapseProps) { - const navigate = useNavigate(); + +export function ConfigFormGithubCollapse({ register }: ConfigFormGithubCollapseProps) { + const isPublic = checkPublicAccess(); + const hasImageRenderer = checkImageRenderer(); return ( -

- Realtime feedback -

- {checkPublicAccess() ? ( -
- - - Changes in git will be quickly pulled into grafana. Pull requests can be processed. - - -
- ) : ( - Instructions
} - onRemove={() => navigate(GETTING_STARTED_URL)} - > - - Changes in git will eventually be pulled depending on the synchronization interval. Pull requests will not - be processed - - - )} - -

- - Pull Request image previews - -

- {!config.rendererAvailable && ( - Instructions} - onRemove={() => window.open('https://grafana.com/grafana/plugins/grafana-image-renderer/', '_blank')} - > - - When the image renderer is configured, pull requests can see preview images - - - )} + + + + Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be + shared in your Git repository and visible to anyone with repository access. + {' '} + + + Requires image rendering.{' '} + + Set up image rendering + + + + + } + {...register('generateDashboardPreviews')} + /> + - - - Render before/after images and link them to the pull request. -
- NOTE: This will render dashboards into an image that can be access by a public URL + {!isPublic && ( + + + + + Configure webhooks + {' '} + to get instant updates in Grafana as soon as changes are committed. Review and approve changes using pull + requests before they go live. - - } - > - {previews} - + +
+ )} ); } diff --git a/public/app/features/provisioning/Config/defaults.ts b/public/app/features/provisioning/Config/defaults.ts new file mode 100644 index 00000000000..4c7d77731ef --- /dev/null +++ b/public/app/features/provisioning/Config/defaults.ts @@ -0,0 +1,25 @@ +import { RepositorySpec } from '../../../api/clients/provisioning'; +import { RepositoryFormData } from '../types'; +import { specToData } from '../utils/data'; + +export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { + if (!repository) { + return { + type: 'github', + title: 'Repository', + token: '', + url: '', + branch: 'main', + generateDashboardPreviews: false, + readOnly: false, + prWorkflow: true, + path: 'grafana/', + sync: { + enabled: false, + target: 'instance', + intervalSeconds: 60, + }, + }; + } + return specToData(repository); +} diff --git a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx index ab4de428af7..d74719dcb5d 100644 --- a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx +++ b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx @@ -17,7 +17,6 @@ const featureIni = `# In your custom.ini file [feature_toggles] provisioning = true -unifiedStorageSearch = true kubernetesClientDashboardsFolders = true kubernetesDashboards = true ; use k8s from browser diff --git a/public/app/features/provisioning/Job/ActiveJobStatus.tsx b/public/app/features/provisioning/Job/ActiveJobStatus.tsx deleted file mode 100644 index 37a9f4442f4..00000000000 --- a/public/app/features/provisioning/Job/ActiveJobStatus.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Job } from 'app/api/clients/provisioning'; - -import { JobContent } from './JobContent'; -import { useJobStatusEffect } from './hooks'; - -export interface ActiveJobProps { - job: Job; - onStatusChange?: (success: boolean) => void; - onRunningChange?: (isRunning: boolean) => void; - onErrorChange?: (error: string | null) => void; -} - -export function ActiveJobStatus({ job, onStatusChange, onRunningChange, onErrorChange }: ActiveJobProps) { - useJobStatusEffect(job, onStatusChange, onRunningChange, onErrorChange); - return ; -} diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 7ab68023055..632a82471af 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -4,8 +4,8 @@ import { Trans } from 'app/core/internationalization'; import { StepStatusInfo } from '../Wizard/types'; -import { ActiveJobStatus } from './ActiveJobStatus'; import { FinishedJobStatus } from './FinishedJobStatus'; +import { JobContent } from './JobContent'; export interface JobStatusProps { watch: Job; @@ -35,8 +35,13 @@ export function JobStatus({ watch, onStatusChange }: JobStatusProps) { ); } + if (activeQuery.isError) { + onStatusChange({ status: 'error', error: 'Error fetching active job' }); + return null; + } + if (activeJob) { - return ; + return ; } if (shouldCheckFinishedJobs) { diff --git a/public/app/features/provisioning/Job/hooks.ts b/public/app/features/provisioning/Job/hooks.ts deleted file mode 100644 index 04fe54b13f1..00000000000 --- a/public/app/features/provisioning/Job/hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect } from 'react'; - -import { Job } from 'app/api/clients/provisioning'; -import { t } from 'app/core/internationalization'; - -// Shared hook for status change effects -export function useJobStatusEffect( - job?: Job, - onStatusChange?: (success: boolean) => void, - onRunningChange?: (isRunning: boolean) => void, - onErrorChange?: (error: string | null) => void -) { - useEffect(() => { - if (!job) { - return; - } - - if (onStatusChange && job.status?.state === 'success') { - onStatusChange(true); - if (onRunningChange) { - onRunningChange(false); - } - } - if (onErrorChange && job.status?.state === 'error') { - onErrorChange(job.status.message ?? t('provisioning.job-status.error-unknown', 'An unknown error occurred')); - if (onRunningChange) { - onRunningChange(false); - } - } - }, [job, onStatusChange, onErrorChange, onRunningChange]); -} diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx index c97bea79c18..2c995490821 100644 --- a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx +++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx @@ -11,7 +11,7 @@ export function TokenPermissionsInfo() {
{/* GitHub UI is English only, so these strings are not translated */} {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - + Go to GitHub Personal Access Tokens diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index dd0d5f167dd..cbcda614c32 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -4,21 +4,21 @@ import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; import { AppEvents, GrafanaTheme2 } from '@grafana/data'; -import { getAppEvents } from '@grafana/runtime'; +import { getAppEvents, isFetchError } from '@grafana/runtime'; import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui'; import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning'; import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { t } from 'app/core/internationalization'; -import { getDefaultValues } from '../Config/ConfigForm'; +import { getDefaultValues } from '../Config/defaults'; import { PROVISIONING_URL } from '../constants'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { dataToSpec } from '../utils/data'; +import { getFormErrors } from '../utils/getFormErrors'; import { BootstrapStep } from './BootstrapStep'; import { ConnectStep } from './ConnectStep'; import { FinishStep } from './FinishStep'; -import { RequestErrorAlert } from './RequestErrorAlert'; import { Step, Stepper } from './Stepper'; import { SynchronizeStep } from './SynchronizeStep'; import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types'; @@ -41,7 +41,7 @@ const getSteps = (): Array> => { }, { id: 'synchronize', - name: t('provisioning.wizard.step-synchronize', 'Synchronize'), + name: t('provisioning.wizard.step-synchronize', 'Synchronize with external storage'), title: t('provisioning.wizard.title-synchronize', 'Synchronize with external storage'), submitOnNext: false, }, @@ -83,11 +83,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { setValue, getValues, trigger, + setError, formState: { isDirty }, } = methods; const repoName = watch('repositoryName'); - const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName); + const [submitData] = useCreateOrUpdateRepository(repoName); const [deleteRepository] = useDeleteRepositoryMutation(); const currentStepIndex = steps.findIndex((s) => s.id === activeStep); @@ -191,10 +192,17 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { console.error('Saved repository without a name:', rsp); } } catch (error) { - setStepStatusInfo({ - status: 'error', - error: 'Repository connection failed', - }); + if (isFetchError(error)) { + const [field, errorMessage] = getFormErrors(error.data.errors); + if (field && errorMessage) { + setError(field, errorMessage); + } + } else { + setStepStatusInfo({ + status: 'error', + error: 'Repository connection failed', + }); + } } finally { setIsSubmitting(false); } @@ -210,7 +218,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { if (activeStep === 'synchronize') { return stepStatusInfo.status !== 'success'; } - return isSubmitting || isCancelling || stepStatusInfo.status === 'running' || stepStatusInfo.status === 'error'; + return ( + isSubmitting || + isCancelling || + stepStatusInfo.status === 'running' || + (activeStep !== 'connection' && stepStatusInfo.status === 'error') + ); }; return ( @@ -228,13 +241,9 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { - + {stepStatusInfo.status === 'error' && ( + + )}
{activeStep === 'connection' && } @@ -252,16 +261,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { {activeStep === 'finish' && }
- {stepStatusInfo.status === 'error' && ( - - )} - -