Merge branch 'main' into 103652-docs-alert-migration-tool

pull/103752/head
Johnny K. 3 months ago
commit 10302140be
  1. 3
      .betterer.results
  2. 2
      .github/CODEOWNERS
  3. 29
      .github/workflows/pr-e2e-tests.yml
  4. 13
      .github/workflows/run-e2e-suite.yml
  5. 1
      apps/advisor/pkg/app/checkregistry/checkregistry.go
  6. 83
      apps/advisor/pkg/app/checks/datasourcecheck/check.go
  7. 204
      apps/advisor/pkg/app/checks/datasourcecheck/check_test.go
  8. 12
      apps/dashboard/kinds/dashboard.cue
  9. 10
      apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
  10. 12
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
  11. 35
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
  12. 90
      apps/dashboard/pkg/migration/conversion/conversion.go
  13. 14
      apps/dashboard/pkg/migration/conversion/conversion_test.go
  14. 7
      conf/defaults.ini
  15. 246
      conf/provisioning/sample/dashboard-v2.json
  16. 2
      conf/sample.ini
  17. 14
      docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md
  18. 93
      docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md
  19. 195
      docs/sources/alerting/configure-notifications/template-notifications/reference.md
  20. 8
      docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md
  21. 8
      docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md
  22. 6
      docs/sources/alerting/monitor-status/view-alert-rules.md
  23. 84
      docs/sources/panels-visualizations/visualizations/annotations/index.md
  24. 43
      docs/sources/panels-visualizations/visualizations/datagrid/index.md
  25. 19
      docs/sources/panels-visualizations/visualizations/news/index.md
  26. 2
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  27. 23
      docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md
  28. 47
      e2e/dashboards-suite/dashboard-links-without-slug.spec.ts
  29. 256
      e2e/dashboards/DataLinkWithoutSlugTest.json
  30. 4
      go.mod
  31. 4
      go.sum
  32. 1
      go.work.sum
  33. 2
      packages/grafana-data/src/types/config.ts
  34. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  35. 1
      packages/grafana-data/src/types/icon.ts
  36. 2
      packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx
  37. 9
      packages/grafana-runtime/src/config.ts
  38. 2
      packages/grafana-runtime/src/internal/index.ts
  39. 17
      packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts
  40. 4
      packages/grafana-runtime/src/utils/DataSourceWithBackend.ts
  41. 10
      packages/grafana-runtime/src/utils/userStorage.test.tsx
  42. 10
      packages/grafana-runtime/src/utils/userStorage.tsx
  43. 8
      packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
  44. 14
      packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
  45. 14
      packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
  46. 6
      packages/grafana-ui/src/components/Combobox/useOptions.ts
  47. 2
      packages/grafana-ui/src/components/Forms/Checkbox.tsx
  48. 2
      packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx
  49. 32
      packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
  50. 1
      pkg/api/dtos/frontend_settings.go
  51. 4
      pkg/api/folder_test.go
  52. 1
      pkg/api/frontendsettings.go
  53. 33
      pkg/api/index.go
  54. 2
      pkg/apis/folder/v1/doc.go
  55. 4
      pkg/apis/folder/v1/register.go
  56. 2
      pkg/apis/folder/v1/types.go
  57. 2
      pkg/apis/folder/v1/zz_generated.deepcopy.go
  58. 2
      pkg/apis/folder/v1/zz_generated.defaults.go
  59. 50
      pkg/apis/folder/v1/zz_generated.openapi.go
  60. 4
      pkg/apis/folder/v1/zz_generated.openapi_violation_exceptions.list
  61. 11
      pkg/apis/provisioning/v0alpha1/types.go
  62. 22
      pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
  63. 48
      pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
  64. 2
      pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go
  65. 8
      pkg/registry/apis/dashboard/large_test.go
  66. 4
      pkg/registry/apis/dashboard/legacy/migrate.go
  67. 4
      pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
  68. 12
      pkg/registry/apis/dashboard/legacysearcher/search_client.go
  69. 16
      pkg/registry/apis/dashboard/mutation_test.go
  70. 64
      pkg/registry/apis/dashboard/register.go
  71. 40
      pkg/registry/apis/dashboard/register_test.go
  72. 6
      pkg/registry/apis/dashboard/search.go
  73. 4
      pkg/registry/apis/folders/authorizer_test.go
  74. 12
      pkg/registry/apis/folders/conversions.go
  75. 4
      pkg/registry/apis/folders/folder_storage.go
  76. 4
      pkg/registry/apis/folders/folder_storage_test.go
  77. 12
      pkg/registry/apis/folders/legacy_storage.go
  78. 12
      pkg/registry/apis/folders/legacy_storage_test.go
  79. 49
      pkg/registry/apis/folders/register.go
  80. 134
      pkg/registry/apis/folders/register_test.go
  81. 8
      pkg/registry/apis/folders/sub_access.go
  82. 12
      pkg/registry/apis/folders/sub_count.go
  83. 22
      pkg/registry/apis/folders/sub_parent_test.go
  84. 22
      pkg/registry/apis/folders/sub_parents.go
  85. 2
      pkg/registry/apis/provisioning/controller/finalizers.go
  86. 14
      pkg/registry/apis/provisioning/controller/repository.go
  87. 6
      pkg/registry/apis/provisioning/jobs/migrate/worker.go
  88. 233
      pkg/registry/apis/provisioning/jobs/pullrequest/changes.go
  89. 208
      pkg/registry/apis/provisioning/jobs/pullrequest/changes_test.go
  90. 124
      pkg/registry/apis/provisioning/jobs/pullrequest/comment.go
  91. 134
      pkg/registry/apis/provisioning/jobs/pullrequest/comment_test.go
  92. 85
      pkg/registry/apis/provisioning/jobs/pullrequest/mock_commenter.go
  93. 101
      pkg/registry/apis/provisioning/jobs/pullrequest/mock_evaluator.go
  94. 351
      pkg/registry/apis/provisioning/jobs/pullrequest/mock_pullrequest_repo.go
  95. 192
      pkg/registry/apis/provisioning/jobs/pullrequest/preview.go
  96. 142
      pkg/registry/apis/provisioning/jobs/pullrequest/preview_renderer_mock.go
  97. 156
      pkg/registry/apis/provisioning/jobs/pullrequest/previewer_mock.go
  98. 56
      pkg/registry/apis/provisioning/jobs/pullrequest/render.go
  99. 144
      pkg/registry/apis/provisioning/jobs/pullrequest/render_mock.go
  100. 11
      pkg/registry/apis/provisioning/jobs/pullrequest/testdata/multiple-files.md
  101. Some files were not shown because too many files have changed in this diff Show More

@ -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 <Trans />", "0"]
],

@ -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

@ -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 }}

@ -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

@ -53,6 +53,7 @@ func (s *Service) Checks() []checks.Check {
s.pluginStore,
s.pluginContextProvider,
s.pluginClient,
s.pluginRepo,
),
plugincheck.New(
s.pluginStore,

@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/util"
@ -20,6 +21,7 @@ const (
CheckID = "datasource"
HealthCheckStepID = "health-check"
UIDValidationStepID = "uid-validation"
MissingPluginStepID = "missing-plugin"
)
type check struct {
@ -27,6 +29,7 @@ type check struct {
PluginStore pluginstore.Store
PluginContextProvider pluginContextProvider
PluginClient plugins.Client
PluginRepo repo.Service
log log.Logger
}
@ -35,12 +38,14 @@ func New(
pluginStore pluginstore.Store,
pluginContextProvider pluginContextProvider,
pluginClient plugins.Client,
pluginRepo repo.Service,
) checks.Check {
return &check{
DatasourceSvc: datasourceSvc,
PluginStore: pluginStore,
PluginContextProvider: pluginContextProvider,
PluginClient: pluginClient,
PluginRepo: pluginRepo,
log: log.New("advisor.datasourcecheck"),
}
}
@ -69,6 +74,11 @@ func (c *check) Steps() []checks.Step {
PluginClient: c.PluginClient,
log: c.log,
},
&missingPluginStep{
PluginStore: c.PluginStore,
PluginRepo: c.PluginRepo,
log: c.log,
},
}
}
@ -145,22 +155,8 @@ func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any
pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotRegistered) {
// The plugin is not installed
return checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
[]advisor.CheckErrorLink{
{
Message: "Delete data source",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
{
Message: "Install plugin",
Url: fmt.Sprintf("/plugins/%s", ds.Type),
},
},
), nil
// The plugin is not installed, handle this in the missing plugin step
return nil, nil
}
// Unable to check health check
s.log.Error("Failed to get plugin context", "datasource_uid", ds.UID, "error", err)
@ -196,6 +192,61 @@ func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any
return nil, nil
}
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
log log.Logger
}
func (s *missingPluginStep) Title() string {
return "Missing plugin check"
}
func (s *missingPluginStep) Description() string {
return "Checks if the plugin associated with the data source is installed."
}
func (s *missingPluginStep) Resolution() string {
return "Delete the datasource or install the plugin."
}
func (s *missingPluginStep) ID() string {
return MissingPluginStepID
}
func (s *missingPluginStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportFailure, error) {
ds, ok := i.(*datasources.DataSource)
if !ok {
return nil, fmt.Errorf("invalid item type %T", i)
}
_, exists := s.PluginStore.Plugin(ctx, ds.Type)
if !exists {
links := []advisor.CheckErrorLink{
{
Message: "Delete data source",
Url: fmt.Sprintf("/connections/datasources/edit/%s", ds.UID),
},
}
_, err := s.PluginRepo.PluginInfo(ctx, ds.Type)
if err == nil {
// Plugin is available in the repo
links = append(links, advisor.CheckErrorLink{
Message: "Install plugin",
Url: fmt.Sprintf("/plugins/%s", ds.Type),
})
}
// The plugin is not installed
return checks.NewCheckReportFailure(
advisor.CheckReportFailureSeverityHigh,
s.ID(),
ds.Name,
links,
), nil
}
return nil, nil
}
type pluginContextProvider interface {
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
}

@ -2,6 +2,7 @@ package datasourcecheck
import (
"context"
"errors"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@ -9,11 +10,37 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/assert"
)
// runChecks executes all steps for all items and returns the failures
func runChecks(check *check) ([]advisor.CheckReportFailure, error) {
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
if err != nil {
return nil, err
}
failures := []advisor.CheckReportFailure{}
for _, step := range check.Steps() {
for _, item := range items {
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
if err != nil {
return nil, err
}
if stepFailures != nil {
failures = append(failures, *stepFailures)
}
}
}
return failures, nil
}
func TestCheck_Run(t *testing.T) {
t.Run("should return no failures when all datasources are healthy", func(t *testing.T) {
datasources := []*datasources.DataSource{
@ -24,30 +51,20 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
failures, err := runChecks(check)
assert.NoError(t, err)
failures := []advisor.CheckReportFailure{}
for _, step := range check.Steps() {
for _, item := range items {
stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item)
assert.NoError(t, err)
if stepFailures != nil {
failures = append(failures, *stepFailures)
}
}
}
assert.NoError(t, err)
assert.Equal(t, 2, len(items))
assert.Empty(t, failures)
})
@ -59,30 +76,20 @@ func TestCheck_Run(t *testing.T) {
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
mockPluginRepo := &MockPluginRepo{exists: true}
mockPluginStore := &MockPluginStore{exists: true}
check := &check{
DatasourceSvc: mockDatasourceSvc,
PluginContextProvider: mockPluginContextProvider,
PluginClient: mockPluginClient,
PluginRepo: mockPluginRepo,
PluginStore: mockPluginStore,
log: log.New("advisor.datasourcecheck"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
items, err := check.Items(ctx)
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
}

@ -1,9 +1,9 @@
package kinds
import (
"github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
"github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
"github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
v0 "github.com/grafana/grafana/sdkkinds/dashboard/v0alpha1"
v1 "github.com/grafana/grafana/sdkkinds/dashboard/v1alpha1"
v2 "github.com/grafana/grafana/sdkkinds/dashboard/v2alpha1"
)
// Status is the shared status of all dashboard versions.
@ -51,19 +51,19 @@ dashboard: {
versions: {
"v0alpha1": {
schema: {
spec: v0alpha1.DashboardSpec
spec: v0.DashboardSpec
status: DashboardStatus
}
}
"v1alpha1": {
schema: {
spec: v1alpha1.DashboardSpec
spec: v1.DashboardSpec
status: DashboardStatus
}
}
"v2alpha1": {
schema: {
spec: v2alpha1.DashboardSpec
spec: v2.DashboardSpec
status: DashboardStatus
}
}

@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@ -523,8 +528,8 @@ GridLayoutRowSpec: {
y: int
collapsed: bool
title: string
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
repeat?: RowRepeatOptions
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
repeat?: RowRepeatOptions
}
GridLayoutSpec: {
@ -604,6 +609,7 @@ TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
conditionalRendering?: ConditionalRenderingGroupKind
repeat?: TabRepeatOptions
}
PanelSpec: {

@ -1069,6 +1069,7 @@ type DashboardTabsLayoutTabSpec struct {
Title *string `json:"title,omitempty"`
Layout DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"`
Repeat *DashboardTabRepeatOptions `json:"repeat,omitempty"`
}
// NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object.
@ -1078,6 +1079,17 @@ func NewDashboardTabsLayoutTabSpec() *DashboardTabsLayoutTabSpec {
}
}
// +k8s:openapi-gen=true
type DashboardTabRepeatOptions struct {
Mode string `json:"mode"`
Value string `json:"value"`
}
// NewDashboardTabRepeatOptions creates a new DashboardTabRepeatOptions object.
func NewDashboardTabRepeatOptions() *DashboardTabRepeatOptions {
return &DashboardTabRepeatOptions{}
}
// Links with references to other dashboards or external resources
// +k8s:openapi-gen=true
type DashboardDashboardLink struct {

@ -100,6 +100,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref),
@ -3921,6 +3922,33 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref common.Refe
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"mode": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"value": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"mode", "value"},
},
},
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@ -4027,12 +4055,17 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref common.Re
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind"),
},
},
"repeat": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"),
},
},
},
Required: []string{"layout"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"},
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"},
}
}

@ -4,55 +4,55 @@ import (
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func RegisterConversions(s *runtime.Scheme) error {
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V1(a.(*v0alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V1(a.(*dashv0.Dashboard), b.(*dashv1.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v0alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2(a.(*v0alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V0_to_V2(a.(*dashv0.Dashboard), b.(*dashv2.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V0(a.(*v1alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V0(a.(*dashv1.Dashboard), b.(*dashv0.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v1alpha1.Dashboard)(nil), (*v2alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V2(a.(*v1alpha1.Dashboard), b.(*v2alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V1_to_V2(a.(*dashv1.Dashboard), b.(*dashv2.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v0alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V0(a.(*v2alpha1.Dashboard), b.(*v0alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv0.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V0(a.(*dashv2.Dashboard), b.(*dashv0.Dashboard), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v2alpha1.Dashboard)(nil), (*v1alpha1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V1(a.(*v2alpha1.Dashboard), b.(*v1alpha1.Dashboard), scope)
if err := s.AddConversionFunc((*dashv2.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_V2_to_V1(a.(*dashv2.Dashboard), b.(*dashv1.Dashboard), scope)
}); err != nil {
return err
}
return nil
}
func Convert_V0_to_V1(in *v0alpha1.Dashboard, out *v1alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V0_to_V1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
out.Spec.Object = in.Spec.Object
out.Status = v1alpha1.DashboardStatus{
Conversion: &v1alpha1.DashboardConversionStatus{
StoredVersion: v0alpha1.VERSION,
out.Status = dashv1.DashboardStatus{
Conversion: &dashv1.DashboardConversionStatus{
StoredVersion: dashv0.VERSION,
},
}
@ -64,7 +64,7 @@ func Convert_V0_to_V1(in *v0alpha1.Dashboard, out *v1alpha1.Dashboard, scope con
return nil
}
func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V0_to_V2(in *dashv0.Dashboard, out *dashv2.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
// TODO (@radiohead): implement V0 to V2 conversion
@ -77,16 +77,16 @@ func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
}
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: v2alpha1.DashboardGridLayoutSpec{},
Spec: dashv2.DashboardGridLayoutSpec{},
},
}
out.Status = v2alpha1.DashboardStatus{
Conversion: &v2alpha1.DashboardConversionStatus{
StoredVersion: v0alpha1.VERSION,
out.Status = dashv2.DashboardStatus{
Conversion: &dashv2.DashboardConversionStatus{
StoredVersion: dashv0.VERSION,
Failed: true,
Error: "backend conversion not yet implemented",
},
@ -95,21 +95,21 @@ func Convert_V0_to_V2(in *v0alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
return nil
}
func Convert_V1_to_V0(in *v1alpha1.Dashboard, out *v0alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
out.Spec.Object = in.Spec.Object
out.Status = v0alpha1.DashboardStatus{
Conversion: &v0alpha1.DashboardConversionStatus{
StoredVersion: v1alpha1.VERSION,
out.Status = dashv0.DashboardStatus{
Conversion: &dashv0.DashboardConversionStatus{
StoredVersion: dashv1.VERSION,
},
}
return nil
}
func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V1_to_V2(in *dashv1.Dashboard, out *dashv2.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
// TODO (@radiohead): implement V1 to V2 conversion
@ -122,16 +122,16 @@ func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
}
// We need to make sure the layout is set to some value, otherwise the JSON marshaling will fail.
out.Spec.Layout = v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &v2alpha1.DashboardGridLayoutKind{
out.Spec.Layout = dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
GridLayoutKind: &dashv2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: v2alpha1.DashboardGridLayoutSpec{},
Spec: dashv2.DashboardGridLayoutSpec{},
},
}
out.Status = v2alpha1.DashboardStatus{
Conversion: &v2alpha1.DashboardConversionStatus{
StoredVersion: v1alpha1.VERSION,
out.Status = dashv2.DashboardStatus{
Conversion: &dashv2.DashboardConversionStatus{
StoredVersion: dashv1.VERSION,
Failed: true,
Error: "backend conversion not yet implemented",
},
@ -140,14 +140,14 @@ func Convert_V1_to_V2(in *v1alpha1.Dashboard, out *v2alpha1.Dashboard, scope con
return nil
}
func Convert_V2_to_V0(in *v2alpha1.Dashboard, out *v0alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V2_to_V0(in *dashv2.Dashboard, out *dashv0.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
// TODO: implement V2 to V0 conversion
out.Status = v0alpha1.DashboardStatus{
Conversion: &v0alpha1.DashboardConversionStatus{
StoredVersion: v2alpha1.VERSION,
out.Status = dashv0.DashboardStatus{
Conversion: &dashv0.DashboardConversionStatus{
StoredVersion: dashv2.VERSION,
Failed: true,
Error: "backend conversion not yet implemented",
},
@ -156,14 +156,14 @@ func Convert_V2_to_V0(in *v2alpha1.Dashboard, out *v0alpha1.Dashboard, scope con
return nil
}
func Convert_V2_to_V1(in *v2alpha1.Dashboard, out *v1alpha1.Dashboard, scope conversion.Scope) error {
func Convert_V2_to_V1(in *dashv2.Dashboard, out *dashv1.Dashboard, scope conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
// TODO: implement V2 to V1 conversion
out.Status = v1alpha1.DashboardStatus{
Conversion: &v1alpha1.DashboardConversionStatus{
StoredVersion: v2alpha1.VERSION,
out.Status = dashv1.DashboardStatus{
Conversion: &dashv1.DashboardConversionStatus{
StoredVersion: dashv2.VERSION,
Failed: true,
Error: "backend conversion not yet implemented",
},

@ -9,18 +9,18 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestConversionMatrixExist(t *testing.T) {
versions := []v1.Object{
&v0alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
&v1alpha1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
&v2alpha1.Dashboard{Spec: v2alpha1.DashboardSpec{Title: "dashboardV2"}},
&dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}},
&dashv1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}},
&dashv2.Dashboard{Spec: dashv2.DashboardSpec{Title: "dashboardV2"}},
}
scheme := runtime.NewScheme()
@ -47,7 +47,7 @@ func TestConversionMatrixExist(t *testing.T) {
}
func TestDeepCopyValid(t *testing.T) {
dash1 := &v0alpha1.Dashboard{}
dash1 := &dashv0.Dashboard{}
meta1, err := utils.MetaAccessor(dash1)
require.NoError(t, err)
meta1.SetFolder("f1")

@ -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

@ -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": []
}
}

@ -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

@ -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="<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 >}}

@ -33,6 +33,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/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/<GRAFANA_VERSION>/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.<variable_name>`. |
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 }}
```

@ -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.

@ -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:

@ -29,6 +29,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/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/<GRAFANA_VERSION>/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/<GRAFANA_VERSION>/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<sup>\*</sup>** | The state of an alert whose query returns no data or all values are null. <br/> 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<sup>\*</sup>** | The state of an alert when an error or timeout occurred evaluating the alert rule. <br/> 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" >}}

@ -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.

@ -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="<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="<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="<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 %}}
<!-- prettier-ignore-start -->
### 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.
<!-- prettier-ignore-end -->
## 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
<!-- prettier-ignore-start -->
### 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.
<!-- prettier-ignore-end -->
- 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.
<!-- prettier-ignore-start -->
### Time after
| Option | Description |
| ---------- | --------------------------------------------------------------------------------------------------------- |
| Link target | Set how to view the annotated data. Choose from:<ul><li>**Panel** - The link takes you directly to a full-screen view of the panel with the corresponding annotation.</li><li>**Dashboard** - Focuses the annotation in the context of a complete dashboard.</li></ul> |
| 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.
<!-- prettier-ignore-end -->

@ -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="<GRAFANA_VERSION>" >}}
### Panel options
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Datagrid options
If there are multiple series, you can choose the dataset the datagrid displays using the **Select series** option.

@ -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/<GRAFANA_VERSION>/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/<GRAFANA_VERSION>/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="<GRAFANA_VERSION>" >}}
## News options
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
Use the following options to refine your news visualization.
### Panel options
### URL
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<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.

@ -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 |

@ -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/<GRAFANA_VERSION>/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**:

@ -0,0 +1,47 @@
import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
import { e2e } from '../utils';
describe('Dashboard with data links that have no slug', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('Should not reload if linking to same dashboard', () => {
cy.intercept({
pathname: '/api/ds/query',
}).as('query');
e2e.flows.importDashboard(testDashboard, 1000, true);
cy.wait('@query');
e2e.components.Panels.Panel.title('Data links without slug').should('exist');
e2e.components.DataLinksContextMenu.singleLink().contains('9yy21uzzxypg').click();
cy.contains('Loading', { timeout: 500 })
.should(() => {}) // prevent test from failing if it does not find loading
.then(throwIfLoadingFound);
cy.url().should('include', urlShouldContain);
e2e.components.DataLinksContextMenu.singleLink().contains('dr199bpvpcru').click();
cy.contains('Loading', { timeout: 500 })
.should(() => {}) // prevent test from failing if it does not find loading
.then(throwIfLoadingFound);
cy.url().should('include', urlShouldContain);
e2e.components.DataLinksContextMenu.singleLink().contains('dre33fzyxcrz').click();
cy.contains('Loading', { timeout: 500 })
.should(() => {}) // prevent test from failing if it does not find loading
.then(throwIfLoadingFound);
cy.url().should('include', urlShouldContain);
});
});
const urlShouldContain = '/d/data-link-no-slug/data-link-without-slug-test';
const throwIfLoadingFound = (el: JQuery) => {
if (el.length) {
// This means dashboard refreshes when clicking self-referencing data link
// that has no slug in it
throw new Error('Should not contain Loading');
}
};

@ -0,0 +1,256 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 135,
"links": [],
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"links": [
{
"title": "",
"url": "/d/${__dashboard.uid}?var-instance=${__data.fields.test1}&${__url_time_range}"
}
],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 4,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "11.6.0-pre",
"targets": [
{
"alias": "test1",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "csv_metric_values",
"stringInput": "9wvfgzurfzb, 9yy21uzzxypg, dr199bpvpcru, dre33fzyxcrz, gc6j7crvrcpf, u6g9zuxvxypv"
}
],
"title": "Data links without slug",
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "gdev-prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0-pre",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "gdev-prometheus"
},
"disableTextWrap": false,
"editorMode": "builder",
"expr": "counters_logins{geohash=\"$instance\"}",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 41,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": "9wvfgzurfzb",
"value": "9wvfgzurfzb"
},
"name": "instance",
"options": [
{
"selected": true,
"text": "9wvfgzurfzb",
"value": "9wvfgzurfzb"
},
{
"selected": false,
"text": "9yy21uzzxypg",
"value": "9yy21uzzxypg"
},
{
"selected": false,
"text": "dr199bpvpcru",
"value": "dr199bpvpcru"
},
{
"selected": false,
"text": "dre33fzyxcrz",
"value": "dre33fzyxcrz"
},
{
"selected": false,
"text": "gc6j7crvrcpf",
"value": "gc6j7crvrcpf"
},
{
"selected": false,
"text": "u6g9zuxvxypv",
"value": "u6g9zuxvxypv"
}
],
"query": "9wvfgzurfzb, 9yy21uzzxypg, dr199bpvpcru, dre33fzyxcrz, gc6j7crvrcpf, u6g9zuxvxypv",
"type": "custom"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "Data Link without slug test",
"uid": "data-link-no-slug",
"version": 3
}

@ -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

@ -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=

@ -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=

@ -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 {

@ -336,6 +336,7 @@ export interface FeatureToggles {
kubernetesDashboards?: boolean;
/**
* Route the folder and dashboard service requests to k8s
* @default true
*/
kubernetesClientDashboardsFolders?: boolean;
/**

@ -269,6 +269,7 @@ export const availableIconsIndex = {
'add-user': true,
attach: true,
'dollar-alt': true,
'ai-sparkle': true,
};
export type IconName = keyof typeof availableIconsIndex;

@ -249,7 +249,7 @@ export function MetricSelect({
return (
<AsyncSelect
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect}
isClearable={Boolean(variableEditor)}
isClearable={true}
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}

@ -184,6 +184,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
rudderstackIntegrationsUrl: undefined;
analyticsConsoleReporting = false;
dashboardPerformanceMetrics: string[] = [];
panelSeriesLimit = 0;
sqlConnectionLimits = {
maxOpenConns: 100,
maxIdleConns: 100,
@ -208,6 +209,12 @@ export class GrafanaBootConfig implements GrafanaConfig {
*/
language: string | undefined;
/**
* Locale used in Grafana's UI. Default to 'es-US' in the backend and overwritten when the user select a different one in SharedPreferences.
* This is the locale that is used for date formatting and other locale-specific features.
*/
locale: string;
constructor(options: GrafanaBootConfig) {
this.bootData = options.bootData;
@ -243,6 +250,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
this.theme2 = getThemeById(this.bootData.user.theme);
this.bootData.user.lightTheme = this.theme2.isLight;
this.theme = this.theme2.v1;
this.locale = options.bootData.user.locale;
}
geomapDefaultBaseLayer?: MapLayerOptions<any> | undefined;
listDashboardScopesEndpoint?: string | undefined;

@ -25,3 +25,5 @@ export {
setGetObservablePluginLinks,
type GetObservablePluginLinks,
} from '../services/pluginExtensions/getObservablePluginLinks';
export { UserStorage } from '../utils/userStorage';

@ -37,6 +37,14 @@ class MyDataSource extends DataSourceWithBackend<MyQuery, DataSourceJsonData> {
applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[] | undefined): MyQuery {
return { ...query, applyTemplateVariablesCalled: true, filters };
}
async getValue(key: string) {
return await this.userStorage.getItem(key);
}
async setValue(key: string, value: string) {
await this.userStorage.setItem(key, value);
}
}
const mockDatasourceRequest = jest.fn<Promise<FetchResponse>, BackendSrvRequest[]>();
@ -536,6 +544,15 @@ describe('DataSourceWithBackend', () => {
expect(publicDashboardQueryHandler).toHaveBeenCalledWith(request);
});
});
describe('user storage', () => {
test('sets and gets a value', async () => {
const { ds } = createMockDatasource();
await ds.setValue('multiplier', '1');
expect(await ds.getValue('multiplier')).toBe('1');
});
});
});
function createMockDatasource() {

@ -34,6 +34,7 @@ import {
import { publicDashboardQueryHandler } from './publicDashboardQueryHandler';
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse';
import { UserStorage } from './userStorage';
/**
* @internal
@ -121,8 +122,11 @@ class DataSourceWithBackend<
TQuery extends DataQuery = DataQuery,
TOptions extends DataSourceJsonData = DataSourceJsonData,
> extends DataSourceApi<TQuery, TOptions> {
protected userStorage: UserStorage;
constructor(instanceSettings: DataSourceInstanceSettings<TOptions>) {
super(instanceSettings);
this.userStorage = new UserStorage(instanceSettings.type);
}
/**

@ -48,15 +48,15 @@ describe('userStorage', () => {
it('use localStorage if the user is not logged in', async () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
await storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalledWith('plugin-id:abc:key');
});
it('use localStorage if the user storage is not found', async () => {
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
const storage = usePluginUserStorage();
await storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
expect(localStorage.getItem).toHaveBeenCalledWith('plugin-id:abc:key');
});
it('returns the value from the user storage', async () => {
@ -73,8 +73,8 @@ describe('userStorage', () => {
it('use localStorage if the user is not logged in', async () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalled();
await storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalledWith('plugin-id:abc:key', 'value');
});
it('creates a new user storage if it does not exist', async () => {

@ -37,9 +37,9 @@ async function apiRequest<T>(requestOptions: RequestOptions) {
/**
* A class for interacting with the backend user storage.
* Unexported because it is currently only be used through the useUserStorage hook.
* Exposed internally only to avoid misuse (wrong service name)..
*/
class UserStorage {
export class UserStorage {
private service: string;
private resourceName: string;
private userUID: string;
@ -76,13 +76,13 @@ class UserStorage {
async getItem(key: string): Promise<string | null> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
return localStorage.getItem(this.resourceName);
return localStorage.getItem(`${this.resourceName}:${key}`);
}
// Ensure this.storageSpec is initialized
await this.init();
if (!this.storageSpec) {
// Also, fallback to localStorage for backward compatibility
return localStorage.getItem(this.resourceName);
return localStorage.getItem(`${this.resourceName}:${key}`);
}
return this.storageSpec.data[key];
}
@ -90,7 +90,7 @@ class UserStorage {
async setItem(key: string, value: string): Promise<void> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
localStorage.setItem(key, value);
localStorage.setItem(`${this.resourceName}:${key}`, value);
return;
}

@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode,
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@ -603,6 +608,7 @@ TabsLayoutTabKind: {
TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
repeat?: TabRepeatOptions
conditionalRendering?: ConditionalRenderingGroupKind
}
@ -962,4 +968,4 @@ ConditionalRenderingTimeRangeSizeKind: {
ConditionalRenderingTimeRangeSizeSpec: {
value: string
}
}

@ -327,7 +327,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@ -916,6 +916,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
repeat?: TabRepeatOptions;
conditionalRendering?: ConditionalRenderingGroupKind;
}
@ -923,6 +924,16 @@ export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
layout: defaultGridLayoutKind(),
});
export interface TabRepeatOptions {
mode: "variable";
value: string;
}
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
mode: RepeatMode,
value: "",
});
// Links with references to other dashboards or external resources
export interface DashboardLink {
// Title to display with the link
@ -1492,4 +1503,3 @@ export const defaultVariableValueOption = (): VariableValueOption => ({
label: "",
value: defaultVariableValueSingle(),
});

@ -282,7 +282,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@ -872,12 +872,23 @@ export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
conditionalRendering?: ConditionalRenderingGroupKind;
repeat?: TabRepeatOptions;
}
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
layout: defaultGridLayoutKind(),
});
export interface TabRepeatOptions {
mode: "variable";
value: string;
}
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
mode: RepeatMode,
value: "",
});
// Links with references to other dashboards or external resources
export interface DashboardLink {
// Title to display with the link
@ -1402,4 +1413,3 @@ export const defaultSpec = (): Spec => ({
title: "",
variables: [],
});

@ -65,10 +65,12 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
(opts: Array<ComboboxOption<T>>) => {
let currentOptions: Array<ComboboxOption<T>> = 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,

@ -212,6 +212,8 @@ export const getCheckboxStyles = (theme: GrafanaTheme2, invalid = false) => {
gridRowStart: 2,
lineHeight: theme.typography.bodySmall.lineHeight,
marginTop: 0 /* The margin effectively comes from the top: -2px on the label above it */,
// Enable interacting with description when checkbox is disabled
zIndex: 1,
})
),
};

@ -159,6 +159,7 @@ export function TableCellNG(props: TableCellNGProps) {
tableCellDiv?.style.setProperty('min-height', `100%`);
tableCellDiv?.style.setProperty('height', `fit-content`);
tableCellDiv?.style.setProperty('background', colors.bgHoverColor || 'none');
tableCellDiv?.style.setProperty('min-width', 'min-content');
}
};
@ -173,6 +174,7 @@ export function TableCellNG(props: TableCellNGProps) {
tableCellDiv?.style.removeProperty('min-height');
tableCellDiv?.style.removeProperty('height');
tableCellDiv?.style.removeProperty('background');
tableCellDiv?.style.removeProperty('min-width');
}
};

@ -865,28 +865,26 @@ export function mapFrameToDataGrid({
});
});
// INFO: This loop calculates the width for each column in less than a millisecond.
// set columns that are at minimum width
let sharedWidth = availableWidth / fieldCountWithoutWidth;
// First pass: Assign minimum widths to columns that need it
columns.forEach((column) => {
if (!column.width && column.minWidth! > sharedWidth) {
column.width = column.minWidth;
availableWidth -= column.width!;
fieldCountWithoutWidth -= 1;
for (let i = fieldCountWithoutWidth; i > 0; i--) {
for (const column of columns) {
if (!column.width && column.minWidth! > sharedWidth) {
column.width = column.minWidth;
availableWidth -= column.width!;
fieldCountWithoutWidth -= 1;
sharedWidth = availableWidth / fieldCountWithoutWidth;
}
}
});
// Recalculate shared width after assigning minimum widths
sharedWidth = availableWidth / fieldCountWithoutWidth;
}
// Second pass: Assign shared width to remaining columns
columns.forEach((column) => {
// divide up the rest of the space
for (const column of columns) {
if (!column.width) {
column.width = sharedWidth;
}
column.minWidth = COLUMN.MIN_WIDTH; // Ensure min-width is always set
});
column.minWidth = COLUMN.MIN_WIDTH;
}
return columns;
}
@ -980,6 +978,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
'--rdg-summary-border-color': theme.colors.border.medium,
'.rdg-cell': {
// Prevent collisions with custom cell components
zIndex: 2,
borderRight: 'none',
},
},

@ -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"`

@ -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,
},
}

@ -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,

@ -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

@ -3,4 +3,4 @@
// +k8s:defaulter-gen=TypeMeta
// +groupName=folder.grafana.app
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
package v1 // import "github.com/grafana/grafana/pkg/apis/folder/v1"

@ -1,4 +1,4 @@
package v0alpha1
package v1
import (
"fmt"
@ -12,7 +12,7 @@ import (
const (
GROUP = "folder.grafana.app"
VERSION = "v0alpha1"
VERSION = "v1"
RESOURCE = "folders"
APIVERSION = GROUP + "/" + VERSION
RESOURCEGROUP = RESOURCE + "." + GROUP

@ -1,4 +1,4 @@
package v0alpha1
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

@ -5,7 +5,7 @@
// Code generated by deepcopy-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
runtime "k8s.io/apimachinery/pkg/runtime"

@ -5,7 +5,7 @@
// Code generated by defaulter-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
runtime "k8s.io/apimachinery/pkg/runtime"

@ -5,7 +5,7 @@
// Code generated by openapi-gen. DO NOT EDIT.
package v0alpha1
package v1
import (
common "k8s.io/kube-openapi/pkg/common"
@ -14,18 +14,18 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.DescendantCounts": schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_Folder(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderAccessInfo": schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_FolderInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfoList": schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_FolderList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats": schema_pkg_apis_folder_v0alpha1_ResourceStats(ref),
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.DescendantCounts": schema_pkg_apis_folder_v1_DescendantCounts(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.Folder": schema_pkg_apis_folder_v1_Folder(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderAccessInfo": schema_pkg_apis_folder_v1_FolderAccessInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo": schema_pkg_apis_folder_v1_FolderInfo(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfoList": schema_pkg_apis_folder_v1_FolderInfoList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderList": schema_pkg_apis_folder_v1_FolderList(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats": schema_pkg_apis_folder_v1_ResourceStats(ref),
"github.com/grafana/grafana/pkg/apis/folder/v1.Spec": schema_pkg_apis_folder_v1_Spec(ref),
}
}
func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -52,7 +52,7 @@ func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallba
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats"),
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats"),
},
},
},
@ -63,11 +63,11 @@ func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallba
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.ResourceStats"},
"github.com/grafana/grafana/pkg/apis/folder/v1.ResourceStats"},
}
}
func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_Folder(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -96,18 +96,18 @@ func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec"),
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.Spec"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
"github.com/grafana/grafana/pkg/apis/folder/v1.Spec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -163,7 +163,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallba
}
}
func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -214,7 +214,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co
}
}
func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_FolderInfoList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -256,7 +256,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo"),
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo"),
},
},
},
@ -266,11 +266,11 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref common.ReferenceCallback
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
"github.com/grafana/grafana/pkg/apis/folder/v1.FolderInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_FolderList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -303,7 +303,7 @@ func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) co
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder"),
Ref: ref("github.com/grafana/grafana/pkg/apis/folder/v1.Folder"),
},
},
},
@ -313,11 +313,11 @@ func schema_pkg_apis_folder_v0alpha1_FolderList(ref common.ReferenceCallback) co
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
"github.com/grafana/grafana/pkg/apis/folder/v1.Folder", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_folder_v0alpha1_ResourceStats(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_ResourceStats(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -351,7 +351,7 @@ func schema_pkg_apis_folder_v0alpha1_ResourceStats(ref common.ReferenceCallback)
}
}
func schema_pkg_apis_folder_v0alpha1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_pkg_apis_folder_v1_Spec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{

@ -1,2 +1,2 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v0alpha1,DescendantCounts,Counts
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v0alpha1,FolderInfoList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v1,DescendantCounts,Counts
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/folder/v1,FolderInfoList,Items

@ -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

@ -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
}

@ -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"},
}
}

@ -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"

@ -10,7 +10,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
)
func TestLargeDashboardSupport(t *testing.T) {
@ -21,7 +21,7 @@ func TestLargeDashboardSupport(t *testing.T) {
f, err := os.ReadFile(devdash)
require.NoError(t, err)
dash := &dashboardv1alpha1.Dashboard{
dash := &dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "test",
@ -38,7 +38,7 @@ func TestLargeDashboardSupport(t *testing.T) {
scheme := runtime.NewScheme()
err = dashboardv1alpha1.AddToScheme(scheme)
err = dashv1.AddToScheme(scheme)
require.NoError(t, err)
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
@ -56,7 +56,7 @@ func TestLargeDashboardSupport(t *testing.T) {
}`, string(small))
// Now make it big again
rehydratedDash := &dashboardv1alpha1.Dashboard{
rehydratedDash := &dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "test",

@ -13,7 +13,7 @@ import (
authlib "github.com/grafana/authlib/types"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
@ -326,7 +326,7 @@ func (a *dashboardSqlAccess) migrateFolders(ctx context.Context, orgId int64, op
// Now send each dashboard
for i := 1; rows.Next(); i++ {
dash := rows.row.Dash
dash.APIVersion = "folder.grafana.app/v0alpha1"
dash.APIVersion = "folder.grafana.app/v1"
dash.Kind = "Folder"
dash.SetNamespace(opts.Namespace)
dash.SetResourceVersion("") // it will be filled in by the backend

@ -132,7 +132,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
}
dash := &dashboard.Dashboard{
TypeMeta: metav1.TypeMeta{
APIVersion: "dashboard.grafana.app/v1alpha1",
APIVersion: dashboard.APIVERSION,
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-dash",
@ -170,7 +170,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
&dashboards.Dashboard{
ID: 1234,
Version: 2,
APIVersion: "dashboard.grafana.app/v1alpha1",
APIVersion: dashboard.APIVERSION,
}, nil).Once()
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
require.NoError(t, err)

@ -14,7 +14,7 @@ import (
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/search/sort"
@ -92,7 +92,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
switch req.Options.Key.Resource {
case dashboard.DASHBOARD_RESOURCE:
queryType = searchstore.TypeDashboard
case folderv0alpha1.RESOURCE:
case folders.RESOURCE:
queryType = searchstore.TypeFolder
default:
return nil, fmt.Errorf("bad type request")
@ -104,7 +104,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
if len(req.Federated) == 1 &&
((req.Federated[0].Resource == dashboard.DASHBOARD_RESOURCE && queryType == searchstore.TypeFolder) ||
(req.Federated[0].Resource == folderv0alpha1.RESOURCE && queryType == searchstore.TypeDashboard)) {
(req.Federated[0].Resource == folders.RESOURCE && queryType == searchstore.TypeDashboard)) {
queryType = "" // makes the legacy store search across both
}
@ -336,8 +336,8 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
if item.IsFolder {
return &resource.ResourceKey{
Namespace: namespace,
Group: folderv0alpha1.GROUP,
Resource: folderv0alpha1.RESOURCE,
Group: folders.GROUP,
Resource: folders.RESOURCE,
Name: item.UID,
}
}
@ -401,7 +401,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.Reso
switch parts[0] {
case dashboard.GROUP:
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, false)
case folderv0alpha1.GROUP:
case folders.GROUP:
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
default:
return nil, fmt.Errorf("invalid group")

@ -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)

@ -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
}

@ -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(),
},
},
}

@ -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
}

@ -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 {

@ -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,
},

@ -12,7 +12,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@ -91,7 +91,7 @@ func (s *folderStorage) Create(ctx context.Context,
return nil, err
}
p, ok := obj.(*v0alpha1.Folder)
p, ok := obj.(*folders.Folder)
if !ok {
return nil, fmt.Errorf("expected folder?")
}

@ -5,7 +5,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/user"
@ -50,7 +50,7 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) {
store: &fakeStorage{},
cfg: cfg,
}
obj := &v0alpha1.Folder{}
obj := &folders.Folder{}
ctx := request.WithNamespace(context.Background(), "org-2")
ctx = identity.WithRequester(ctx, &user.SignedInUser{

@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -116,7 +116,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
return nil, err
}
list := &v0alpha1.FolderList{}
list := &folders.FolderList{}
for _, v := range hits {
r, err := convertToK8sResource(v, s.namespacer)
if err != nil {
@ -178,7 +178,7 @@ func (s *legacyStorage) Create(ctx context.Context,
return nil, err
}
p, ok := obj.(*v0alpha1.Folder)
p, ok := obj.(*folders.Folder)
if !ok {
return nil, fmt.Errorf("expected folder?")
}
@ -248,11 +248,11 @@ func (s *legacyStorage) Update(ctx context.Context,
if err != nil {
return oldObj, created, err
}
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folders.Folder)
if !ok {
return nil, created, fmt.Errorf("expected folder after update")
}
old, ok := oldObj.(*v0alpha1.Folder)
old, ok := oldObj.(*folders.Folder)
if !ok {
return nil, created, fmt.Errorf("expected old object to be a folder also")
}
@ -313,7 +313,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
if err != nil {
return nil, false, err
}
p, ok := v.(*v0alpha1.Folder)
p, ok := v.(*folders.Folder)
if !ok {
return v, false, fmt.Errorf("expected a folder response from Get")
}

@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folderv1 "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/org"
@ -57,7 +57,7 @@ func TestLegacyStorageList(t *testing.T) {
uidsReturnedByList := []string{}
for _, obj := range list {
f, ok := obj.(*v0alpha1.Folder)
f, ok := obj.(*folderv1.Folder)
require.Equal(t, true, ok)
uidsReturnedByList = append(uidsReturnedByList, f.Name)
}
@ -89,7 +89,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
result, err := storage.List(ctx, options)
require.NoError(t, err)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
token, err := base64.StdEncoding.DecodeString(list.Continue)
require.NoError(t, err)
@ -113,7 +113,7 @@ func TestLegacyStorage_List_Pagination(t *testing.T) {
result, err := storage.List(ctx, options)
require.NoError(t, err)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
token, err := base64.StdEncoding.DecodeString(list.Continue)
require.NoError(t, err)
@ -152,7 +152,7 @@ func TestLegacyStorage_List_LabelSelector(t *testing.T) {
require.False(t, folderService.LastQuery.WithFullpath)
require.False(t, folderService.LastQuery.WithFullpathUIDs)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
require.Len(t, list.Items, 1)
})
@ -181,7 +181,7 @@ func TestLegacyStorage_List_LabelSelector(t *testing.T) {
require.True(t, folderService.LastQuery.WithFullpath)
require.True(t, folderService.LastQuery.WithFullpathUIDs)
list, ok := result.(*v0alpha1.FolderList)
list, ok := result.(*folderv1.FolderList)
require.True(t, ok)
require.Len(t, list.Items, 1)

@ -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) {

@ -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,

@ -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()

@ -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,

@ -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"}},
}},

@ -10,7 +10,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
)
type subParentsREST struct {
@ -21,7 +21,7 @@ var _ = rest.Connecter(&subParentsREST{})
var _ = rest.StorageMetadata(&subParentsREST{})
func (r *subParentsREST) New() runtime.Object {
return &v0alpha1.FolderInfoList{}
return &folders.FolderInfoList{}
}
func (r *subParentsREST) Destroy() {
@ -36,7 +36,7 @@ func (r *subParentsREST) ProducesMIMETypes(verb string) []string {
}
func (r *subParentsREST) ProducesObject(verb string) interface{} {
return &v0alpha1.FolderInfoList{}
return &folders.FolderInfoList{}
}
func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) {
@ -48,7 +48,7 @@ func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.
if err != nil {
return nil, err
}
folder, ok := obj.(*v0alpha1.Folder)
folder, ok := obj.(*folders.Folder)
if !ok {
return nil, fmt.Errorf("expecting folder, found: %T", folder)
}
@ -61,13 +61,13 @@ func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.
}), nil
}
func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *v0alpha1.FolderInfoList {
info := &v0alpha1.FolderInfoList{
Items: []v0alpha1.FolderInfo{},
func (r *subParentsREST) parents(ctx context.Context, folder *folders.Folder) *folders.FolderInfoList {
info := &folders.FolderInfoList{
Items: []folders.FolderInfo{},
}
for folder != nil {
parent := getParent(folder)
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: folder.Name,
Title: folder.Spec.Title,
Description: folder.Spec.Description,
@ -79,7 +79,7 @@ func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *
obj, err := r.getter.Get(ctx, parent, &metav1.GetOptions{})
if err != nil {
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: parent,
Detached: true,
Description: err.Error(),
@ -87,9 +87,9 @@ func (r *subParentsREST) parents(ctx context.Context, folder *v0alpha1.Folder) *
break
}
parentFolder, ok := obj.(*v0alpha1.Folder)
parentFolder, ok := obj.(*folders.Folder)
if !ok {
info.Items = append(info.Items, v0alpha1.FolderInfo{
info.Items = append(info.Items, folders.FolderInfo{
Name: parent,
Detached: true,
Description: fmt.Sprintf("expected folder, found: %T", obj),

@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/utils"
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"

@ -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

@ -142,7 +142,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
}
namespace := rw.Config().Namespace
progress.SetMessage(ctx, "loading legacy folders")
progress.SetMessage(ctx, "loading folders from SQL")
reader := NewLegacyFolderReader(w.legacyMigrator, rw.Config().Name, namespace)
if err = reader.Read(ctx, w.legacyMigrator, rw.Config().Name, namespace); err != nil {
return fmt.Errorf("error loading folder tree: %w", err)
@ -154,7 +154,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
}
folders := resources.NewFolderManager(rw, folderClient, resources.NewEmptyFolderTree())
progress.SetMessage(ctx, "exporting legacy folders")
progress.SetMessage(ctx, "exporting folders from SQL")
err = folders.EnsureFolderTreeExists(ctx, "", "", reader.Tree(), func(folder resources.Folder, created bool, err error) error {
result := jobs.JobResourceResult{
Action: repository.FileActionCreated,
@ -176,7 +176,7 @@ func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.R
return fmt.Errorf("error exporting legacy folders: %w", err)
}
progress.SetMessage(ctx, "exporting legacy resources")
progress.SetMessage(ctx, "exporting resources from SQL")
resourceManager := resources.NewResourcesManager(rw, folders, parser, clients, userInfo)
for _, kind := range resources.SupportedProvisioningResources {
if kind == resources.FolderResource {

@ -0,0 +1,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
}

@ -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]
}

@ -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
}

@ -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)
})
}
}

@ -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
}

@ -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
}

@ -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
}

@ -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
}

@ -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
}

@ -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
}

@ -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
}

@ -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
}

@ -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.

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save