Compare commits

..

2 Commits

Author SHA1 Message Date
Jayclifford345 f37986e97b prettier 2025-12-12 13:10:11 +00:00
Jayclifford345 29ad717011 make sue component is registered to show side bar 2025-12-12 13:01:49 +00:00
101 changed files with 448 additions and 3884 deletions
@@ -1603,6 +1603,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1670,6 +1671,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1687,6 +1689,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1754,6 +1757,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1784,6 +1788,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1852,6 +1857,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 8,
"min": 1,
"noise": 2,
@@ -1869,6 +1875,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1937,6 +1944,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 12,
"min": 1,
"noise": 2,
@@ -1954,6 +1962,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -2021,6 +2030,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -2038,6 +2048,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -2105,6 +2116,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -2117,147 +2129,6 @@
],
"title": "Backend",
"type": "radialbar"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 66
},
"id": 35,
"panels": [],
"title": "Empty data",
"type": "row"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 67
},
"id": 36,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 0
}
],
"title": "Numeric, no series",
"type": "gauge"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 67
},
"id": 37,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "logs"
}
],
"title": "Non-numeric",
"type": "gauge"
}
],
"preload": false,
@@ -133,12 +133,6 @@ type ExportJobOptions struct {
// FIXME: we should validate this in admission hooks
// Prefix in target file system
Path string `json:"path,omitempty"`
// Resources to export
// This option has been created because currently the frontend does not use
// standarized app platform APIs. For performance and API consistency reasons, the preferred option
// is it to use the resources.
Resources []ResourceRef `json:"resources,omitempty"`
}
type MigrateJobOptions struct {
@@ -204,7 +198,6 @@ type JobStatus struct {
Finished int64 `json:"finished,omitempty"`
Message string `json:"message,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// Optional value 0-100 that can be set while running
Progress float64 `json:"progress,omitempty"`
@@ -232,20 +225,18 @@ type JobResourceSummary struct {
Kind string `json:"kind,omitempty"`
Total int64 `json:"total,omitempty"` // the count (if known)
Create int64 `json:"create,omitempty"`
Update int64 `json:"update,omitempty"`
Delete int64 `json:"delete,omitempty"`
Write int64 `json:"write,omitempty"` // Create or update (export)
Error int64 `json:"error,omitempty"` // The error count
Warning int64 `json:"warning,omitempty"` // The warning count
Create int64 `json:"create,omitempty"`
Update int64 `json:"update,omitempty"`
Delete int64 `json:"delete,omitempty"`
Write int64 `json:"write,omitempty"` // Create or update (export)
Error int64 `json:"error,omitempty"` // The error count
// No action required (useful for sync)
Noop int64 `json:"noop,omitempty"`
// Report errors/warnings for this resource type
// Report errors for this resource type
// This may not be an exhaustive list and recommend looking at the logs for more info
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// HistoricJob is an append only log, saving all jobs that have been processed.
@@ -88,11 +88,6 @@ func (in *ErrorDetails) DeepCopy() *ErrorDetails {
// 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
if in.Resources != nil {
in, out := &in.Resources, &out.Resources
*out = make([]ResourceRef, len(*in))
copy(*out, *in)
}
return
}
@@ -406,11 +401,6 @@ func (in *JobResourceSummary) DeepCopyInto(out *JobResourceSummary) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Warnings != nil {
in, out := &in.Warnings, &out.Warnings
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
@@ -435,7 +425,7 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) {
if in.Push != nil {
in, out := &in.Push, &out.Push
*out = new(ExportJobOptions)
(*in).DeepCopyInto(*out)
**out = **in
}
if in.Pull != nil {
in, out := &in.Pull, &out.Pull
@@ -478,11 +468,6 @@ func (in *JobStatus) DeepCopyInto(out *JobStatus) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Warnings != nil {
in, out := &in.Warnings, &out.Warnings
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Summary != nil {
in, out := &in.Summary, &out.Summary
*out = make([]*JobResourceSummary, len(*in))
@@ -258,25 +258,9 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference
Format: "",
},
},
"resources": {
SchemaProps: spec.SchemaProps{
Description: "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"},
}
}
@@ -905,13 +889,6 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
Format: "int64",
},
},
"warning": {
SchemaProps: spec.SchemaProps{
Description: "The error count",
Type: []string{"integer"},
Format: "int64",
},
},
"noop": {
SchemaProps: spec.SchemaProps{
Description: "No action required (useful for sync)",
@@ -921,7 +898,7 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
},
"errors": {
SchemaProps: spec.SchemaProps{
Description: "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
Description: "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
@@ -934,20 +911,6 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
},
},
},
"warnings": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
},
},
@@ -1066,20 +1029,6 @@ func schema_pkg_apis_provisioning_v0alpha1_JobStatus(ref common.ReferenceCallbac
},
},
},
"warnings": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"progress": {
SchemaProps: spec.SchemaProps{
Description: "Optional value 0-100 that can be set while running",
@@ -1,13 +1,10 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ExportJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Warnings
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Summary
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Warnings
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Resources
@@ -7,11 +7,10 @@ package v0alpha1
// ExportJobOptionsApplyConfiguration represents a declarative configuration of the ExportJobOptions type for use
// with apply.
type ExportJobOptionsApplyConfiguration struct {
Message *string `json:"message,omitempty"`
Folder *string `json:"folder,omitempty"`
Branch *string `json:"branch,omitempty"`
Path *string `json:"path,omitempty"`
Resources []ResourceRefApplyConfiguration `json:"resources,omitempty"`
Message *string `json:"message,omitempty"`
Folder *string `json:"folder,omitempty"`
Branch *string `json:"branch,omitempty"`
Path *string `json:"path,omitempty"`
}
// ExportJobOptionsApplyConfiguration constructs a declarative configuration of the ExportJobOptions type for use with
@@ -51,16 +50,3 @@ func (b *ExportJobOptionsApplyConfiguration) WithPath(value string) *ExportJobOp
b.Path = &value
return b
}
// WithResources adds the given value to the Resources field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Resources field.
func (b *ExportJobOptionsApplyConfiguration) WithResources(values ...*ResourceRefApplyConfiguration) *ExportJobOptionsApplyConfiguration {
for i := range values {
if values[i] == nil {
panic("nil value passed to WithResources")
}
b.Resources = append(b.Resources, *values[i])
}
return b
}
@@ -7,18 +7,16 @@ package v0alpha1
// JobResourceSummaryApplyConfiguration represents a declarative configuration of the JobResourceSummary type for use
// with apply.
type JobResourceSummaryApplyConfiguration struct {
Group *string `json:"group,omitempty"`
Kind *string `json:"kind,omitempty"`
Total *int64 `json:"total,omitempty"`
Create *int64 `json:"create,omitempty"`
Update *int64 `json:"update,omitempty"`
Delete *int64 `json:"delete,omitempty"`
Write *int64 `json:"write,omitempty"`
Error *int64 `json:"error,omitempty"`
Warning *int64 `json:"warning,omitempty"`
Noop *int64 `json:"noop,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Group *string `json:"group,omitempty"`
Kind *string `json:"kind,omitempty"`
Total *int64 `json:"total,omitempty"`
Create *int64 `json:"create,omitempty"`
Update *int64 `json:"update,omitempty"`
Delete *int64 `json:"delete,omitempty"`
Write *int64 `json:"write,omitempty"`
Error *int64 `json:"error,omitempty"`
Noop *int64 `json:"noop,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// JobResourceSummaryApplyConfiguration constructs a declarative configuration of the JobResourceSummary type for use with
@@ -91,14 +89,6 @@ func (b *JobResourceSummaryApplyConfiguration) WithError(value int64) *JobResour
return b
}
// WithWarning sets the Warning field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Warning field is set to the value of the last call.
func (b *JobResourceSummaryApplyConfiguration) WithWarning(value int64) *JobResourceSummaryApplyConfiguration {
b.Warning = &value
return b
}
// WithNoop sets the Noop field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Noop field is set to the value of the last call.
@@ -116,13 +106,3 @@ func (b *JobResourceSummaryApplyConfiguration) WithErrors(values ...string) *Job
}
return b
}
// WithWarnings adds the given value to the Warnings field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Warnings field.
func (b *JobResourceSummaryApplyConfiguration) WithWarnings(values ...string) *JobResourceSummaryApplyConfiguration {
for i := range values {
b.Warnings = append(b.Warnings, values[i])
}
return b
}
@@ -16,7 +16,6 @@ type JobStatusApplyConfiguration struct {
Finished *int64 `json:"finished,omitempty"`
Message *string `json:"message,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Progress *float64 `json:"progress,omitempty"`
Summary []*provisioningv0alpha1.JobResourceSummary `json:"summary,omitempty"`
URLs *RepositoryURLsApplyConfiguration `json:"url,omitempty"`
@@ -70,16 +69,6 @@ func (b *JobStatusApplyConfiguration) WithErrors(values ...string) *JobStatusApp
return b
}
// WithWarnings adds the given value to the Warnings field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Warnings field.
func (b *JobStatusApplyConfiguration) WithWarnings(values ...string) *JobStatusApplyConfiguration {
for i := range values {
b.Warnings = append(b.Warnings, values[i])
}
return b
}
// WithProgress sets the Progress field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Progress field is set to the value of the last call.
-35
View File
@@ -7,7 +7,6 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// ValidateJob performs validation on the Job specification and returns an error if validation fails
@@ -100,40 +99,6 @@ func validateExportJobOptions(opts *provisioning.ExportJobOptions) field.ErrorLi
}
}
// Validate resources if specified
if len(opts.Resources) > 0 {
for i, r := range opts.Resources {
resourcePath := field.NewPath("spec", "push", "resources").Index(i)
// Validate required fields
if r.Name == "" {
list = append(list, field.Required(resourcePath.Child("name"), "resource name is required"))
}
if r.Kind == "" {
list = append(list, field.Required(resourcePath.Child("kind"), "resource kind is required"))
}
if r.Group == "" {
list = append(list, field.Required(resourcePath.Child("group"), "resource group is required"))
}
// Validate that folders are not allowed
if r.Kind == resources.FolderKind.Kind || r.Group == resources.FolderResource.Group {
list = append(list, field.Invalid(resourcePath, r, "folders are not supported for export"))
continue // Skip further validation for folders
}
// Validate that only supported resources are allowed
// Currently only Dashboard resources are supported (folders are rejected above)
if r.Kind != "" && r.Group != "" {
// Check if it's a Dashboard resource
isDashboard := r.Group == resources.DashboardResource.Group && r.Kind == "Dashboard"
if !isDashboard {
list = append(list, field.Invalid(resourcePath, r, "resource type is not supported for export"))
}
}
}
}
return list
}
@@ -575,242 +575,6 @@ func TestValidateJob(t *testing.T) {
},
wantErr: false,
},
{
name: "push action with valid dashboard resources",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "dashboard-2",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
Path: "dashboards/",
Message: "Export dashboards",
},
},
},
wantErr: false,
},
{
name: "push action with resource missing name",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].name")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with resource missing kind",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].kind")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with resource missing group",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].group")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with folder resource by kind",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
{
name: "push action with folder resource by group",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-folder",
Kind: "SomeKind",
Group: "folder.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
{
name: "push action with unsupported resource type",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-resource",
Kind: "AlertRule",
Group: "alerting.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "resource type is not supported for export")
},
},
{
name: "push action with valid folder (old behavior)",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Folder: "my-folder",
Path: "dashboards/",
Message: "Export folder",
},
},
},
wantErr: false,
},
{
name: "push action with multiple resources including invalid ones",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "my-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
{
Name: "dashboard-2",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[1]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
}
for _, tt := range tests {
@@ -288,18 +288,18 @@ func (r *localRepository) calculateFileHash(path string) (string, int64, error)
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
func (r *localRepository) Create(ctx context.Context, filePath string, ref string, data []byte, comment string) error {
func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
fpath := safepath.Join(r.path, filePath)
fpath := safepath.Join(r.path, filepath)
_, err := os.Stat(fpath)
if !errors.Is(err, os.ErrNotExist) {
if err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
}
return apierrors.NewAlreadyExists(schema.GroupResource{}, filePath)
return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath)
}
if safepath.IsDir(fpath) {
@@ -314,7 +314,7 @@ func (r *localRepository) Create(ctx context.Context, filePath string, ref strin
return nil
}
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
@@ -352,7 +352,7 @@ func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []b
return os.MkdirAll(fpath, 0700)
}
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
@@ -75,9 +75,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -152,9 +152,9 @@
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -229,9 +229,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -306,9 +306,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -383,9 +383,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -460,9 +460,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -537,9 +537,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -627,9 +627,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -704,9 +704,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -781,9 +781,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -858,9 +858,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -952,9 +952,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1029,9 +1029,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1106,9 +1106,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1183,9 +1183,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1260,9 +1260,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1354,9 +1354,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1435,9 +1435,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1516,9 +1516,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1565,6 +1565,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1605,9 +1606,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1630,6 +1631,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1647,6 +1649,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1687,9 +1690,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1712,6 +1715,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1742,6 +1746,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1783,9 +1788,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1808,6 +1813,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 8,
"min": 1,
"noise": 2,
@@ -1825,6 +1831,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1866,9 +1873,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1891,6 +1898,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 12,
"min": 1,
"noise": 2,
@@ -1908,6 +1916,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1948,9 +1957,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1973,6 +1982,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -1990,6 +2000,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -2030,9 +2041,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -2055,6 +2066,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -2067,147 +2079,6 @@
],
"title": "Backend",
"type": "radialbar"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 66
},
"id": 35,
"panels": [],
"title": "Empty data",
"type": "row"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 67
},
"id": 36,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 0
}
],
"title": "Numeric, no series",
"type": "gauge"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 67
},
"id": 37,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "logs"
}
],
"title": "Non-numeric",
"type": "gauge"
}
],
"preload": false,
@@ -2224,5 +2095,5 @@
"timezone": "browser",
"title": "Panel tests - Gauge (new)",
"uid": "panel-tests-gauge-new",
"version": 9
"version": 6
}
@@ -59,9 +59,9 @@ For more details on contact points, including how to test them and enable notifi
## Alertmanager settings
| Option | Description |
| ------ | ----------------------------------------------------------------------------------------------------------------- |
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Option | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
#### Optional settings
@@ -49,14 +49,14 @@ For more details on contact points, including how to test them and enable notifi
### Required Settings
| Key | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
| Key | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
### Optional Settings
@@ -54,10 +54,10 @@ For more details on contact points, including how to test them and enable notifi
### Required Settings
| Option | Description |
| ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Topic | The topic to which the message will be sent. |
| Option | Description |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
| Topic | The topic to which the message will be sent. |
### Optional Settings
@@ -51,8 +51,8 @@ You can customize the `title` and `body` of the Slack message using [notificatio
If you are using a Slack API Token, complete the following steps.
1. Follow step 1 of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#creating) to create the app.
1. Continue onto the second step of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#scopes) and add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope as described to give your app the ability to post in all public channels without joining.
1. Follow steps 1 and 2 of the [Slack API Quickstart](https://api.slack.com/start/quickstart).
1. Add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope to give your app the ability to post in all public channels without joining.
1. In OAuth Tokens for Your Workspace, copy the Bot User OAuth Token.
1. Open your Slack workplace.
1. Right click the channel you want to receive notifications in.
@@ -62,9 +62,9 @@ For more details on contact points, including how to test them and enable notifi
## Webhook settings
| Option | Description |
| ------ | ------------------------------------------------------------------------------------------------------------ |
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Option | Description |
| ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
#### Optional settings
@@ -81,7 +81,7 @@ Replace the placeholders with your values:
In your `grafana` directory, create a sub-folder called `dashboards`.
This guide shows you how to create three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud Stack
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
@@ -1,147 +0,0 @@
---
title: Git Sync deployment scenarios
menuTitle: Deployment scenarios
description: Learn about common Git Sync deployment patterns and configurations for different organizational needs
weight: 450
keywords:
- git sync
- deployment patterns
- scenarios
- multi-environment
- teams
---
# Git Sync deployment scenarios
This guide shows practical deployment scenarios for Grafanas Git Sync. Learn how to configure bidirectional synchronization between Grafana and Git repositories for teams, environments, and regions.
{{< admonition type="caution" >}}
Git Sync is an experimental feature. It reflects Grafanas approach to Observability as Code and might include limitations or breaking changes. For current status and known limitations, refer to the [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/).
{{< /admonition >}}
## Understand the relationship between key Git Sync components
Before you explore the scenarios, understand how the key Git Sync components relate:
- [Grafana instance](#grafana-instance)
- [Git repository structure](#git-repository-structure)
- [Git Sync repository resource](#git-sync-repository-resource)
### Grafana instance
A Grafana instance is a running Grafana server. Multiple instances can:
- Connect to the same Git repository using different Repository configurations.
- Sync from different branches of the same repository.
- Sync from different paths within the same repository.
- Sync from different repositories.
### Git repository structure
You can organize your Git repository in several ways:
- Single branch, multiple paths: Use different directories for different purposes (for example, `dev/`, `prod/`, `team-a/`).
- Multiple branches: Use different branches for different environments or teams (for example, `main`, `develop`, `team-a`).
- Multiple repositories: Use separate repositories for different teams or environments.
### Git Sync repository resource
A repository resource is a Grafana configuration object that defines:
- Which Git repository to sync with.
- Which branch to use.
- Which directory path to synchronize.
- Sync behavior and workflows.
Each repository resource creates bidirectional synchronization between a Grafana instance and a specific location in Git.
## How does repository sync behave?
With Git Sync you configure a repository resource to sync with your Grafana instance:
1. Grafana monitors the specified Git location (repository, branch, and path).
2. Grafana creates a folder in Dashboards (typically named after the repository).
3. Grafana creates dashboards from dashboard JSON files in Git within this folder.
4. Grafana commits dashboard changes made in the UI back to Git.
5. Grafana pulls dashboard changes made in Git and updates dashboards in the UI.
6. Synchronization occurs at regular intervals (configurable), or instantly if you use webhooks.
You can find the provisioned dashboards organized in folders under **Dashboards**.
## Example: Relationship between repository, branch, and path
Here's a concrete example showing how the three parameters work together:
**Configuration:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-platform/grafana/`
**In Git (on branch `main`):**
```
your-org/grafana-manifests/
├── .git/
├── README.md
├── team-platform/
│ └── grafana/
│ ├── cpu-metrics.json ← Synced
│ ├── memory-usage.json ← Synced
│ └── disk-io.json ← Synced
├── team-data/
│ └── grafana/
│ └── pipeline-stats.json ← Not synced (different path)
└── other-files.txt ← Not synced (outside path)
```
**In Grafana Dashboards view:**
```
Dashboards
└── 📁 grafana-manifests/
├── CPU Metrics Dashboard
├── Memory Usage Dashboard
└── Disk I/O Dashboard
```
**Key points:**
- Grafana only synchronizes files within the specified path (`team-platform/grafana/`).
- Grafana ignores files in other paths or at the repository root.
- The folder name in Grafana comes from the repository name.
- Dashboard titles come from the JSON file content, not the filename.
## Repository configuration flexibility
Git Sync repositories support different combinations of repository URL, branch, and path:
- Different Git repositories: Each environment or team can use its own repository.
- Instance A: `repository: your-org/grafana-prod`.
- Instance B: `repository: your-org/grafana-dev`.
- Different branches: Use separate branches within the same repository.
- Instance A: `repository: your-org/grafana-manifests, branch: main`.
- Instance B: `repository: your-org/grafana-manifests, branch: develop`.
- Different paths: Use different directory paths within the same repository.
- Instance A: `repository: your-org/grafana-manifests, branch: main, path: production/`.
- Instance B: `repository: your-org/grafana-manifests, branch: main, path: development/`.
- Any combination: Mix and match based on your workflow requirements.
## Scenarios
Use these deployment scenarios to plan your Git Sync setup:
- [Single instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance/)
- [Git Sync for development and production environments](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod/)
- [Git Sync with regional replication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region/)
- [High availability](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability/)
- [Git Sync in a shared Grafana instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team/)
## Learn more
Refer to the following documents to learn more:
- [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/)
- [Git Sync setup guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-setup/)
- [Dashboard provisioning](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/)
- [Observability as Code](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/)
@@ -1,147 +0,0 @@
---
title: Git Sync for development and production environments
menuTitle: Across environments
description: Use separate Grafana instances for development and production with Git-controlled promotion
weight: 20
---
# Git Sync for development and production environments
Use separate Grafana instances for development and production. Each syncs with different Git locations to test dashboards before production.
## Use it for
- **Staged deployments**: You need to test dashboard changes before production deployment.
- **Change control**: You require approvals before dashboards reach production.
- **Quality assurance**: You verify dashboard functionality in a non-production environment.
- **Risk mitigation**: You minimize the risk of breaking production dashboards.
## Architecture
```
┌────────────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ ├── dev/ │
│ │ ├── dashboard-new.json ← Development dashboards │
│ │ └── dashboard-test.json │
│ │ │
│ └── prod/ │
│ ├── dashboard-stable.json ← Production dashboards │
│ └── dashboard-approved.json │
└────────────────────────────────────────────────────────────┘
↕ ↕
Git Sync (dev/) Git Sync (prod/)
↕ ↕
┌─────────────────────┐ ┌─────────────────────┐
│ Dev Grafana │ │ Prod Grafana │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: dev/ │ │ - path: prod/ │
│ │ │ │
│ Creates folder: │ │ Creates folder: │
│ "grafana-manifests"│ │ "grafana-manifests"│
└─────────────────────┘ └─────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
├── dev/
│ ├── dashboard-new.json
│ └── dashboard-test.json
└── prod/
├── dashboard-stable.json
└── dashboard-approved.json
```
**In Grafana Dashboards view:**
**Dev instance:**
```
Dashboards
└── 📁 grafana-manifests/
├── New Dashboard
└── Test Dashboard
```
**Prod instance:**
```
Dashboards
└── 📁 grafana-manifests/
├── Stable Dashboard
└── Approved Dashboard
```
- Both instances create a folder named "grafana-manifests" (from repository name)
- Each instance only shows dashboards from its configured path (`dev/` or `prod/`)
- Dashboards appear with their titles from the JSON files
## Configuration parameters
Development:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `dev/`
Production:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `prod/`
## How it works
1. Developers create and modify dashboards in development.
2. Git Sync commits changes to `dev/`.
3. You review changes in Git.
4. You promote approved dashboards from `dev/` to `prod/`.
5. Production syncs from `prod/`.
6. Production dashboards update.
## Alternative: Use branches
Instead of using different paths, you can configure instances to use different branches:
**Development instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `develop`
- **Path**: `grafana/`
**Production instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `grafana/`
With this approach:
- Development changes go to the `develop` branch
- Use Git merge or pull request workflows to promote changes from `develop` to `main`
- Production automatically syncs from the `main` branch
## Alternative: Use separate repositories for stricter isolation
For stricter isolation, use completely separate repositories:
**Development instance:**
- **Repository**: `your-org/grafana-manifests-dev`
- **Branch**: `main`
- **Path**: `grafana/`
**Production instance:**
- **Repository**: `your-org/grafana-manifests-prod`
- **Branch**: `main`
- **Path**: `grafana/`
@@ -1,217 +0,0 @@
---
title: Git Sync for high availability environments
menuTitle: High availability
description: Run multiple Grafana instances serving traffic simultaneously, synchronized via Git Sync
weight: 50
---
# Git Sync for high availability environments
## Primaryreplica scenario
Use a primary Grafana instance and one or more replicas synchronized with the same Git location to enable failover.
### Use it for
- **Automatic failover**: You need service continuity when the primary instance fails.
- **High availability**: Your organization requires guaranteed dashboard availability.
- **Simple HA setup**: You want high availability without the complexity of activeactive.
- **Maintenance windows**: You perform updates while another instance serves traffic.
- **Business continuity**: Dashboard access can't tolerate downtime.
### Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-metrics.json │
│ ├── dashboard-alerts.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ Master Grafana │ │ Replica Grafana │
│ (Active) │ │ (Standby) │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
│ │
└───────────┬───────────────────┘
┌──────────────────────┐
│ Reverse Proxy │
│ (Failover) │
└──────────────────────┘
```
### Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-metrics.json
├── dashboard-alerts.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (both instances):**
```
Dashboards
└── 📁 grafana-manifests/
├── Metrics Dashboard
├── Alerts Dashboard
└── Logs Dashboard
```
- Master and replica instances show identical folder structure.
- Both sync from the same `shared/` path.
- Reverse proxy routes traffic to master (active) instance.
- If master fails, proxy automatically fails over to replica (standby).
- Users see the same dashboards regardless of which instance is serving traffic.
### Configuration parameters
Both master and replica instances use identical parameters:
**Master instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
**Replica instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
### How it works
1. Both instances stay synchronized through Git.
2. Reverse proxy routes traffic to primary.
3. Users edit on primary. Git Sync commits changes.
4. Both instances pull latest changes to keep replica in sync.
5. On primary failure, proxy fails over to replica.
### Failover considerations
- Health checks and monitoring.
- Continuous syncing to minimize data loss.
- Plan failback (automatic or manual).
## Load balancer scenario
Run multiple active Grafana instances behind a load balancer. All instances sync from the same Git location.
### Use it for
- **High traffic**: Your deployment needs to handle significant user load.
- **Load distribution**: You want to distribute user requests across instances.
- **Maximum availability**: You need service continuity during maintenance or failures.
- **Scalability**: You want to add instances as load increases.
- **Performance**: Users need fast response times under heavy load.
### Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-metrics.json │
│ ├── dashboard-alerts.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ Grafana Instance 1│ │ Grafana Instance 2│
│ (Active) │ │ (Active) │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
│ │
└───────────┬───────────────────┘
┌──────────────────────┐
│ Load Balancer │
│ (Round Robin) │
└──────────────────────┘
```
### Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-metrics.json
├── dashboard-alerts.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (all instances):**
```
Dashboards
└── 📁 grafana-manifests/
├── Metrics Dashboard
├── Alerts Dashboard
└── Logs Dashboard
```
- All instances show identical folder structure.
- All instances sync from the same `shared/` path.
- Load balancer distributes requests across all active instances.
- Any instance can serve read requests.
- Any instance can accept dashboard modifications.
- Changes propagate to all instances through Git.
### Configuration parameters
All instances use identical parameters:
**Instance 1:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
**Instance 2:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
### How it works
1. All instances stay synchronized through Git.
2. Load balancer distributes incoming traffic across all active instances.
3. Users can view dashboards from any instance.
4. When a user modifies a dashboard on any instance, Git Sync commits the change.
5. All other instances pull the updated dashboard during their next sync cycle, or instantly if webhooks are configured.
6. If one instance fails, load balancer stops routing traffic to it and remaining instances continue serving.
### Important considerations
- **Eventually consistent**: Due to sync intervals, instances may briefly have different dashboard versions.
- **Concurrent edits**: Multiple users editing the same dashboard on different instances can cause conflicts.
- **Database sharing**: Instances should share the same backend database for user sessions, preferences, and annotations.
- **Stateless design**: Design for stateless operation where possible to maximize load balancing effectiveness.
@@ -1,93 +0,0 @@
---
title: Git Sync with regional replication
menuTitle: Regional replication
description: Synchronize multiple regional Grafana instances from a shared Git location
weight: 30
---
# Git Sync with regional replication
Deploy multiple Grafana instances across regions. Synchronize them with the same Git location to ensure consistent dashboards everywhere.
## Use it for
- **Geographic distribution**: You deploy Grafana close to users in different regions.
- **Latency reduction**: Users need fast dashboard access from their location.
- **Data sovereignty**: You keep dashboard data in specific regions.
- **High availability**: You need dashboard availability across regions.
- **Consistent experience**: All users see the same dashboards regardless of region.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-global.json │
│ ├── dashboard-metrics.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ US Region │ │ EU Region │
│ Grafana │ │ Grafana │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-global.json
├── dashboard-metrics.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (all regions):**
```
Dashboards
└── 📁 grafana-manifests/
├── Global Dashboard
├── Metrics Dashboard
└── Logs Dashboard
```
- All regional instances (US, EU, etc.) show identical folder structure
- Same folder name "grafana-manifests" in every region
- Same dashboards synced from the `shared/` path appear everywhere
- Users in any region see the exact same dashboards with the same titles
## Configuration parameters
All regions:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `shared/`
## How it works
1. All regional instances pull dashboards from `shared/`.
2. Any regions change commits to Git.
3. Other regions pull updates during the next sync (or via webhooks).
4. Changes propagate across regions per sync interval.
## Considerations
- **Write conflicts**: If users in different regions modify the same dashboard simultaneously, Git uses last-write-wins.
- **Primary region**: Consider designating one region as the primary location for making dashboard changes.
- **Propagation time**: Changes propagate to all regions within the configured sync interval, or instantly if webhooks are configured.
- **Network reliability**: Ensure all regions have reliable connectivity to the Git repository.
@@ -1,169 +0,0 @@
---
title: Multiple team Git Sync
menuTitle: Shared instance
description: Use multiple Git repositories with one Grafana instance, one repository per team
weight: 60
---
# Git Sync in a Grafana instance shared by multiple teams
Use a single Grafana instance with multiple Repository resources, one per team. Each team manages its own dashboards while sharing Grafana.
## Use it for
- **Team autonomy**: Different teams manage their own dashboards independently.
- **Organizational structure**: Dashboard organization aligns with team structure.
- **Resource efficiency**: Multiple teams share Grafana infrastructure.
- **Cost optimization**: You reduce infrastructure costs while maintaining team separation.
- **Collaboration**: Teams can view each others dashboards while managing their own.
## Architecture
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ Platform Team Repo │ │ Data Team Repo │
│ platform-dashboards │ │ data-dashboards │
│ │ │ │
│ platform-dashboards/ │ │ data-dashboards/ │
│ └── grafana/ │ │ └── grafana/ │
│ ├── k8s.json │ │ ├── pipeline.json │
│ └── infra.json │ │ └── analytics.json │
└─────────────────────────┘ └─────────────────────────┘
↕ ↕
Git Sync (grafana/) Git Sync (grafana/)
↕ ↕
┌──────────────────────────────────────┐
│ Grafana Instance │
│ │
│ Repository 1: │
│ - repo: platform-dashboards │
│ → Creates "platform-dashboards" │
│ │
│ Repository 2: │
│ - repo: data-dashboards │
│ → Creates "data-dashboards" │
└──────────────────────────────────────┘
```
## Repository structure
**In Git (separate repositories):**
**Platform team repository:**
```
your-org/platform-dashboards
└── grafana/
├── dashboard-k8s.json
└── dashboard-infra.json
```
**Data team repository:**
```
your-org/data-dashboards
└── grafana/
├── dashboard-pipeline.json
└── dashboard-analytics.json
```
**In Grafana Dashboards view:**
```
Dashboards
├── 📁 platform-dashboards/
│ ├── Kubernetes Dashboard
│ └── Infrastructure Dashboard
└── 📁 data-dashboards/
├── Pipeline Dashboard
└── Analytics Dashboard
```
- Two separate folders created (one per Repository resource).
- Folder names derived from repository names.
- Each team has complete control over their own repository.
- Teams can independently manage permissions, branches, and workflows in their repos.
- All teams can view each other's dashboards in Grafana but manage only their own.
## Configuration parameters
**Platform team repository:**
- **Repository**: `your-org/platform-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
**Data team repository:**
- **Repository**: `your-org/data-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
## How it works
1. Each team has their own Git repository for complete autonomy.
2. Each repository resource in Grafana creates a separate folder.
3. Platform team dashboards sync from `your-org/platform-dashboards` repository.
4. Data team dashboards sync from `your-org/data-dashboards` repository.
5. Teams can independently manage their repository settings, access controls, and workflows.
6. All teams can view each other's dashboards in Grafana but edit only their own.
## Scale to more teams
Adding additional teams is straightforward. For a third team, create a new repository and configure:
- **Repository**: `your-org/security-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
This creates a new "security-dashboards" folder in the same Grafana instance.
## Alternative: Shared repository with different paths
For teams that prefer sharing a single repository, use different paths to separate team dashboards:
**In Git:**
```
your-org/grafana-manifests
├── team-platform/
│ ├── dashboard-k8s.json
│ └── dashboard-infra.json
└── team-data/
├── dashboard-pipeline.json
└── dashboard-analytics.json
```
**Configuration:**
**Platform team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-platform/`
**Data team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-data/`
This approach provides simpler repository management but less isolation between teams.
## Alternative: Different branches per team
For teams wanting their own branch in a shared repository:
**Platform team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `team-platform`
- **Path**: `grafana/`
**Data team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `team-data`
- **Path**: `grafana/`
This allows teams to use Git branch workflows for collaboration while sharing the same repository.
@@ -1,86 +0,0 @@
---
title: Single instance Git Sync
menuTitle: Single instance
description: Synchronize a single Grafana instance with a Git repository
weight: 10
---
# Single instance Git Sync
Use a single Grafana instance synchronized with a Git repository. This is the foundation for Git Sync and helps you understand bidirectional synchronization.
## Use it for
- **Getting started**: You want to learn how Git Sync works before implementing complex scenarios.
- **Personal projects**: Individual developers manage their own dashboards.
- **Small teams**: You have a simple setup without multiple environments or complex workflows.
- **Development environments**: You need quick prototyping and testing.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── grafana/ │
│ ├── dashboard-1.json │
│ ├── dashboard-2.json │
│ └── dashboard-3.json │
└─────────────────────────────────────────────────────┘
Git Sync (bidirectional)
┌─────────────────────────────┐
│ Grafana Instance │
│ │
│ Repository Resource: │
│ - url: grafana-manifests │
│ - branch: main │
│ - path: grafana/ │
│ │
│ Creates folder: │
│ "grafana-manifests" │
└─────────────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
└── grafana/
├── dashboard-1.json
├── dashboard-2.json
└── dashboard-3.json
```
**In Grafana Dashboards view:**
```
Dashboards
└── 📁 grafana-manifests/
├── Dashboard 1
├── Dashboard 2
└── Dashboard 3
```
- A folder named "grafana-manifests" (from repository name) contains all synced dashboards.
- Each JSON file becomes a dashboard with its title displayed in the folder.
- Users browse dashboards organized under this folder structure.
## Configuration parameters
Configure your Grafana instance to synchronize with:
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `grafana/`
## How it works
1. **From Grafana to Git**: When users create or modify dashboards in Grafana, Git Sync commits changes to the `grafana/` directory on the `main` branch.
2. **From Git to Grafana**: When dashboard JSON files are added or modified in the `grafana/` directory, Git Sync pulls these changes into Grafana.
@@ -367,6 +367,5 @@ To learn more about using Git Sync:
- [Work with provisioned dashboards](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/provisioned-dashboards/)
- [Manage provisioned repositories with Git Sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/use-git-sync/)
- [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios)
- [Export resources](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/export-resources/)
- [grafanactl documentation](https://grafana.github.io/grafanactl/)
@@ -127,13 +127,7 @@ An instance can be in one of the following Git Sync states:
## Common use cases
{{< admonition type="note" >}}
Refer to [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios) for sample scenarios, including architecture and configuration details.
{{< /admonition >}}
You can use Git Sync for the following use cases:
You can use Git Sync in the following scenarios.
### Version control and auditing
@@ -14,7 +14,7 @@ labels:
- cloud
title: Manage provisioned repositories with Git Sync
menuTitle: Manage repositories with Git Sync
weight: 400
weight: 120
canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/use-git-sync/
aliases:
- ../../../observability-as-code/provision-resources/use-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/use-git-sync/
@@ -1108,8 +1108,6 @@ export type ExportJobOptions = {
message?: string;
/** FIXME: we should validate this in admission hooks Prefix in target file system */
path?: string;
/** Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources. */
resources?: ResourceRef[];
};
export type JobSpec = {
/** Possible enum values:
@@ -1140,7 +1138,7 @@ export type JobResourceSummary = {
delete?: number;
/** Create or update (export) */
error?: number;
/** Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */
/** Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */
errors?: string[];
group?: string;
kind?: string;
@@ -1148,9 +1146,6 @@ export type JobResourceSummary = {
noop?: number;
total?: number;
update?: number;
/** The error count */
warning?: number;
warnings?: string[];
write?: number;
};
export type RepositoryUrLs = {
@@ -1181,7 +1176,6 @@ export type JobStatus = {
summary?: JobResourceSummary[];
/** URLs contains URLs for the reference branch or commit if applicable. */
url?: RepositoryUrLs;
warnings?: string[];
};
export type Job = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
@@ -106,11 +106,6 @@ export function RadialGauge(props: RadialGaugeProps) {
const gaugeId = useId();
const styles = useStyles2(getStyles);
let effectiveTextMode = textMode;
if (effectiveTextMode === 'auto') {
effectiveTextMode = vizCount === 1 ? 'value' : 'value_and_name';
}
const startAngle = shape === 'gauge' ? 250 : 0;
const endAngle = shape === 'gauge' ? 110 : 360;
@@ -193,7 +188,7 @@ export function RadialGauge(props: RadialGaugeProps) {
// These elements are only added for first value / bar
if (barIndex === 0) {
if (glowBar) {
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} barWidth={dimensions.barWidth} />);
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} radius={dimensions.radius} />);
}
if (glowCenter) {
@@ -203,14 +198,14 @@ export function RadialGauge(props: RadialGaugeProps) {
graphics.push(
<RadialText
key="radial-text"
textMode={effectiveTextMode}
vizCount={vizCount}
textMode={textMode}
displayValue={displayValue.display}
dimensions={dimensions}
theme={theme}
valueManualFontSize={props.valueManualFontSize}
nameManualFontSize={props.nameManualFontSize}
shape={shape}
sparkline={displayValue.sparkline}
/>
);
@@ -259,7 +254,6 @@ export function RadialGauge(props: RadialGaugeProps) {
theme={theme}
color={color}
shape={shape}
textMode={effectiveTextMode}
/>
);
}
@@ -1,9 +1,11 @@
import { css } from '@emotion/css';
import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data';
import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
interface RadialSparklineProps {
@@ -12,22 +14,23 @@ interface RadialSparklineProps {
theme: GrafanaTheme2;
color?: string;
shape?: RadialShape;
textMode: Exclude<RadialTextMode, 'auto'>;
}
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
const { radius, barWidth } = dimensions;
export function RadialSparkline({ sparkline, dimensions, theme, color, shape }: RadialSparklineProps) {
if (!sparkline) {
return null;
}
const showNameAndValue = textMode === 'value_and_name';
const height = radius / (showNameAndValue ? 4 : 3);
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
const topPos =
shape === 'gauge'
? `${dimensions.gaugeBottomY - height}px`
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
const { radius, barWidth } = dimensions;
const height = radius / 4;
const widthFactor = shape === 'gauge' ? 1.6 : 1.4;
const width = radius * widthFactor - barWidth;
const topPos = shape === 'gauge' ? `${dimensions.gaugeBottomY - height}px` : `calc(50% + ${radius / 2.8}px)`;
const styles = css({
position: 'absolute',
top: topPos,
});
const config: FieldConfig<GraphFieldConfig> = {
color: {
@@ -42,7 +45,7 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape, te
};
return (
<div style={{ position: 'absolute', top: topPos }}>
<div className={styles}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
</div>
);
@@ -1,12 +1,6 @@
import { css } from '@emotion/css';
import {
DisplayValue,
DisplayValueAlignmentFactors,
FieldSparkline,
formattedValueToString,
GrafanaTheme2,
} from '@grafana/data';
import { DisplayValue, DisplayValueAlignmentFactors, formattedValueToString, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
@@ -14,13 +8,21 @@ import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
// function toCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) {
// let radian = ((angleInDegrees - 90) * Math.PI) / 180.0;
// return {
// x: centerX + radius * Math.cos(radian),
// y: centerY + radius * Math.sin(radian),
// };
// }
interface RadialTextProps {
displayValue: DisplayValue;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
textMode: Exclude<RadialTextMode, 'auto'>;
textMode: RadialTextMode;
vizCount: number;
shape: RadialShape;
sparkline?: FieldSparkline;
alignmentFactors?: DisplayValueAlignmentFactors;
valueManualFontSize?: number;
nameManualFontSize?: number;
@@ -31,8 +33,8 @@ export function RadialText({
theme,
dimensions,
textMode,
vizCount,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
@@ -44,6 +46,10 @@ export function RadialText({
return null;
}
if (textMode === 'auto') {
textMode = vizCount === 1 ? 'value' : 'value_and_name';
}
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
@@ -53,7 +59,7 @@ export function RadialText({
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.82;
const valueWidthToRadiusFactor = 0.85;
const nameToHeightFactor = 0.45;
const largeRadiusScalingDecay = 0.86;
@@ -92,23 +98,18 @@ export function RadialText({
const valueHeight = valueFontSize * lineHeight;
const nameHeight = nameFontSize * lineHeight;
const valueY = showName ? centerY - nameHeight * 0.3 : centerY;
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
const valueY = showName ? centerY - nameHeight / 2 : centerY;
const valueNameSpacing = valueHeight / 3.5;
const nameY = showValue ? valueY + valueHeight / 2 + valueNameSpacing : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= 8;
}
// For gauge shape we shift text up a bit
const valueDy = shape === 'gauge' ? -valueFontSize * 0.3 : 0;
const nameDy = shape === 'gauge' ? -nameFontSize * 0.7 : 0;
return (
<g transform={`translate(0, ${yOffset})`}>
<g>
{showValue && (
<text
x={centerX}
@@ -118,6 +119,7 @@ export function RadialText({
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
dy={valueDy}
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
@@ -131,6 +133,7 @@ export function RadialText({
fontSize={nameFontSize}
x={centerX}
y={nameY}
dy={nameDy}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
@@ -4,12 +4,11 @@ import { GaugeDimensions } from './utils';
export interface GlowGradientProps {
id: string;
barWidth: number;
radius: number;
}
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
// 0.75 is the minimum glow size, and it scales with bar width
const glowSize = 0.75 + barWidth * 0.08;
export function GlowGradient({ id, radius }: GlowGradientProps) {
const glowSize = 0.02 * radius;
return (
<filter id={id} filterUnits="userSpaceOnUse">
@@ -83,7 +82,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
<>
<defs>
<radialGradient id={gradientId} r={'50%'} fr={'0%'}>
<stop offset="0%" stopColor={color} stopOpacity={0.15} />
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
</defs>
@@ -16,7 +16,7 @@ export interface SparklineProps extends Themeable2 {
sparkline: FieldSparkline;
}
const SparklineFn: React.FC<SparklineProps> = memo((props) => {
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
@@ -30,14 +30,4 @@ const SparklineFn: React.FC<SparklineProps> = memo((props) => {
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
SparklineFn.displayName = 'Sparkline';
// we converted to function component above, but some apps extend Sparkline, so we need
// to keep exporting a class component until those apps are all rolled out.
// see https://github.com/grafana/app-observability-plugin/pull/2079
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
export class Sparkline extends React.PureComponent<SparklineProps> {
render() {
return <SparklineFn {...this.props} />;
}
}
Sparkline.displayName = 'Sparkline';
-29
View File
@@ -1,29 +0,0 @@
package auditing
import (
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// NoopBackend is a no-op implementation of audit.Backend
type NoopBackend struct{}
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
func (NoopBackend) Shutdown() {}
func (NoopBackend) String() string { return "" }
// NoopPolicyRuleEvaluator is a no-op implementation of audit.PolicyRuleEvaluator
type NoopPolicyRuleEvaluator struct{}
func ProvideNoopPolicyRuleEvaluator() audit.PolicyRuleEvaluator { return &NoopPolicyRuleEvaluator{} }
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
}
+16 -24
View File
@@ -61,24 +61,20 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
}
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
}()
return s.datasources.GetDataSource(ctx, name)
}
// Create implements rest.Creater.
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
ds, ok := obj.(*v0alpha1.DataSource)
if !ok {
@@ -89,12 +85,10 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
// Update implements rest.Updater.
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
old, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
@@ -132,12 +126,10 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
// Delete implements rest.GracefulDeleter.
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
err := s.datasources.DeleteDataSource(ctx, name)
return nil, false, err
+15 -27
View File
@@ -3,7 +3,6 @@ package datasource
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
@@ -39,14 +38,14 @@ var (
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
datasourceResourceInfo utils.ResourceInfo
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
dataSourceCRUDMetric *prometheus.HistogramVec
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
}
func RegisterAPIService(
@@ -67,16 +66,6 @@ func RegisterAPIService(
var err error
var builder *DataSourceAPIBuilder
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
regErr := reg.Register(dataSourceCRUDMetric)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr
}
pluginJSONs, err := getDatasourcePlugins(pluginSources)
if err != nil {
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
@@ -102,7 +91,6 @@ func RegisterAPIService(
if err != nil {
return nil, err
}
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
apiRegistrar.RegisterAPI(builder)
}
@@ -173,10 +161,6 @@ func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion {
return b.datasourceResourceInfo.GroupVersion()
}
func (b *DataSourceAPIBuilder) SetDataSourceCRUDMetrics(datasourceCRUDMetric *prometheus.HistogramVec) {
b.dataSourceCRUDMetric = datasourceCRUDMetric
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&datasourceV0.DataSource{},
@@ -234,9 +218,13 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
if b.configCrudUseNewApis {
legacyStore := &legacyStorage{
datasources: b.datasources,
resourceInfo: &ds,
dsConfigHandlerRequestsDuration: b.dataSourceCRUDMetric,
datasources: b.datasources,
resourceInfo: &ds,
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"}),
}
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter)
if err != nil {
@@ -13,7 +13,6 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
@@ -24,58 +23,8 @@ import (
// The response status indicates the original stored version, so we can then request it in an un-converted form
type conversionShim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error)
// createDashboardConversionShim creates a conversion shim for dashboards that preserves the original API version.
// It uses a provided versionClients cache to allow sharing across multiple shim calls.
func createDashboardConversionShim(ctx context.Context, clients resources.ResourceClients, gvr schema.GroupVersionResource, versionClients map[string]dynamic.ResourceInterface) conversionShim {
shim := func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v0 we can simply fallback -- the full model is saved
if strings.HasPrefix(storedVersion, "v0") {
item.SetAPIVersion(fmt.Sprintf("%s/%s", gvr.Group, storedVersion))
return item, nil
}
// For any other version (v1, v2, v3, etc.), fetch the original version via client
// Check if we already have a client cached for this version
versionClient, ok := versionClients[storedVersion]
if !ok {
// Dynamically construct the GroupVersionResource for any version
versionGVR := schema.GroupVersionResource{
Group: gvr.Group,
Version: storedVersion,
Resource: gvr.Resource,
}
var err error
versionClient, _, err = clients.ForResource(ctx, versionGVR)
if err != nil {
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
}
versionClients[storedVersion] = versionClient
}
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
return shim
}
func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
progress.SetMessage(ctx, "start resource export")
// Create a shared versionClients map for dashboard conversion caching
versionClients := make(map[string]dynamic.ResourceInterface)
for _, kind := range resources.SupportedProvisioningResources {
// skip from folders as we do them first... so only dashboards
if kind == resources.FolderResource {
@@ -89,10 +38,50 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
// When requesting dashboards over the v1 api, we want to keep the original apiVersion if conversion fails
// Always use the cache version to share clients across all dashboard exports
var shim conversionShim
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
shim = createDashboardConversionShim(ctx, clients, kind, versionClients)
// Cache clients for different versions
versionClients := make(map[string]dynamic.ResourceInterface)
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v0 we can simply fallback -- the full model is saved
if strings.HasPrefix(storedVersion, "v0") {
item.SetAPIVersion(fmt.Sprintf("%s/%s", kind.Group, storedVersion))
return item, nil
}
// For any other version (v1, v2, v3, etc.), fetch the original version via client
// Check if we already have a client cached for this version
versionClient, ok := versionClients[storedVersion]
if !ok {
// Dynamically construct the GroupVersionResource for any version
versionGVR := schema.GroupVersionResource{
Group: kind.Group,
Version: storedVersion,
Resource: kind.Resource,
}
var err error
versionClient, _, err = clients.ForResource(ctx, versionGVR)
if err != nil {
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
}
versionClients[storedVersion] = versionClient
}
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
}
if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
@@ -103,320 +92,6 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
return nil
}
// ExportSpecificResources exports a list of specific resources identified by ResourceRef entries.
// It validates that resources are not folders, are supported, and are unmanaged.
// Note: The caller must validate that the repository has a folder sync target before calling this function.
func ExportSpecificResources(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
if len(options.Resources) == 0 {
return errors.New("no resources specified for export")
}
progress.SetMessage(ctx, "exporting specific resources")
tree, err := loadUnmanagedFolderTree(ctx, clients, progress)
if err != nil {
return err
}
// Create a shared dashboard conversion shim and cache for all dashboard resources
// Create the versionClients map once so it's shared across all dashboard conversion calls
var dashboardShim conversionShim
versionClients := make(map[string]dynamic.ResourceInterface)
for _, resourceRef := range options.Resources {
if err := exportSingleResource(ctx, resourceRef, options, clients, repositoryResources, tree, &dashboardShim, versionClients, progress); err != nil {
return err
}
}
return nil
}
// loadUnmanagedFolderTree loads all unmanaged folders into a tree structure.
// This is needed to resolve folder paths for resources when exporting.
func loadUnmanagedFolderTree(ctx context.Context, clients resources.ResourceClients, progress jobs.JobProgressRecorder) (resources.FolderTree, error) {
progress.SetMessage(ctx, "loading folder tree from API server")
folderClient, err := clients.Folder(ctx)
if err != nil {
return nil, fmt.Errorf("get folder client: %w", err)
}
tree := resources.NewEmptyFolderTree()
if err := resources.ForEach(ctx, folderClient, func(item *unstructured.Unstructured) error {
if tree.Count() >= resources.MaxNumberOfFolders {
return errors.New("too many folders")
}
meta, err := utils.MetaAccessor(item)
if err != nil {
return fmt.Errorf("extract meta accessor: %w", err)
}
manager, _ := meta.GetManagerProperties()
// Skip if already managed by any manager (repository, file provisioning, etc.)
if manager.Identity != "" {
return nil
}
return tree.AddUnstructured(item)
}); err != nil {
return nil, fmt.Errorf("load folder tree: %w", err)
}
return tree, nil
}
// exportSingleResource exports a single resource, handling validation, fetching, conversion, and writing.
func exportSingleResource(
ctx context.Context,
resourceRef provisioning.ResourceRef,
options provisioning.ExportJobOptions,
clients resources.ResourceClients,
repositoryResources resources.RepositoryResources,
tree resources.FolderTree,
dashboardShim *conversionShim,
versionClients map[string]dynamic.ResourceInterface,
progress jobs.JobProgressRecorder,
) error {
result := jobs.JobResourceResult{
Name: resourceRef.Name,
Group: resourceRef.Group,
Kind: resourceRef.Kind,
Action: repository.FileActionCreated,
}
gvk := schema.GroupVersionKind{
Group: resourceRef.Group,
Kind: resourceRef.Kind,
// Version is left empty so ForKind will use the preferred version
}
// Validate resource reference
if err := validateResourceRef(gvk, &result, progress, ctx); err != nil {
return err
}
if result.Error != nil {
// Validation failed, but we continue processing other resources
return nil
}
// Get client and fetch resource
progress.SetMessage(ctx, fmt.Sprintf("Fetching resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
client, gvr, err := clients.ForKind(ctx, gvk)
if err != nil {
result.Error = fmt.Errorf("get client for %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, result)
return progress.TooManyErrors()
}
// Validate resource type is supported
if err := validateResourceType(gvr, &result, progress, ctx); err != nil {
return err
}
if result.Error != nil {
return nil
}
// Fetch and validate the resource
item, meta, err := fetchAndValidateResource(ctx, client, resourceRef, gvr, &result, progress)
if err != nil {
return err
}
if result.Error != nil {
return nil
}
// Convert dashboard if needed
item, meta, err = convertDashboardIfNeeded(ctx, gvr, item, meta, clients, dashboardShim, versionClients, resourceRef, &result, progress)
if err != nil {
return err
}
if result.Error != nil {
return nil
}
// Compute export path from folder tree
exportPath := computeExportPath(options.Path, meta, tree)
// Export the resource
return writeResourceToRepository(ctx, item, meta, exportPath, options.Branch, repositoryResources, resourceRef, &result, progress)
}
// validateResourceRef validates that a resource reference is not a folder.
func validateResourceRef(gvk schema.GroupVersionKind, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
if gvk.Kind == resources.FolderKind.Kind || gvk.Group == resources.FolderResource.Group {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("folders are not supported for export")
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
return nil
}
// validateResourceType validates that a resource type is supported for export.
func validateResourceType(gvr schema.GroupVersionResource, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
isSupported := false
for _, supported := range resources.SupportedProvisioningResources {
if supported.Group == gvr.Group && supported.Resource == gvr.Resource {
isSupported = true
break
}
}
if !isSupported {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("resource type %s/%s is not supported for export", gvr.Group, gvr.Resource)
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
return nil
}
// fetchAndValidateResource fetches a resource from the API server and validates it's unmanaged.
func fetchAndValidateResource(
ctx context.Context,
client dynamic.ResourceInterface,
resourceRef provisioning.ResourceRef,
gvr schema.GroupVersionResource,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
item, err := client.Get(ctx, resourceRef.Name, metav1.GetOptions{})
if err != nil {
result.Error = fmt.Errorf("get resource %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
meta, err := utils.MetaAccessor(item)
if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
manager, _ := meta.GetManagerProperties()
// Reject if already managed by any manager (repository, file provisioning, etc.)
if manager.Identity != "" {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("resource %s/%s/%s is managed and cannot be exported", resourceRef.Group, resourceRef.Kind, resourceRef.Name)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
return item, meta, nil
}
// convertDashboardIfNeeded converts a dashboard to its original API version if needed.
// Returns the potentially updated item and meta accessor.
func convertDashboardIfNeeded(
ctx context.Context,
gvr schema.GroupVersionResource,
item *unstructured.Unstructured,
meta utils.GrafanaMetaAccessor,
clients resources.ResourceClients,
dashboardShim *conversionShim,
versionClients map[string]dynamic.ResourceInterface,
resourceRef provisioning.ResourceRef,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
if gvr.GroupResource() != resources.DashboardResource.GroupResource() {
return item, meta, nil
}
// Create or reuse the dashboard shim (shared across all dashboard resources)
// Pass the shared versionClients map to ensure client caching works correctly
if *dashboardShim == nil {
*dashboardShim = createDashboardConversionShim(ctx, clients, gvr, versionClients)
}
var err error
item, err = (*dashboardShim)(ctx, item)
if err != nil {
result.Error = fmt.Errorf("converting dashboard %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
// Re-extract meta after shim conversion in case the item changed
meta, err = utils.MetaAccessor(item)
if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("extracting meta accessor after conversion for resource %s: %w", result.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
return item, meta, nil
}
// computeExportPath computes the export path by combining the base path with the folder path from the tree.
func computeExportPath(basePath string, meta utils.GrafanaMetaAccessor, tree resources.FolderTree) string {
exportPath := basePath
resourceFolder := meta.GetFolder()
if resourceFolder != "" {
// Get the folder path from the unmanaged tree (rootFolder is empty string for unmanaged tree)
fid, ok := tree.DirPath(resourceFolder, "")
if !ok {
// Folder not found in tree - this shouldn't happen for unmanaged folders
// but if it does, we'll just use the base path
return exportPath
}
if fid.Path != "" {
if exportPath != "" {
exportPath = safepath.Join(exportPath, fid.Path)
} else {
exportPath = fid.Path
}
}
}
return exportPath
}
// writeResourceToRepository writes a resource to the repository.
func writeResourceToRepository(
ctx context.Context,
item *unstructured.Unstructured,
meta utils.GrafanaMetaAccessor,
exportPath string,
branch string,
repositoryResources resources.RepositoryResources,
resourceRef provisioning.ResourceRef,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) error {
// Export the resource
progress.SetMessage(ctx, fmt.Sprintf("Exporting resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
var err error
// exportPath already includes the folder structure from the unmanaged tree.
// We need to clear the folder metadata so WriteResourceFileFromObject doesn't try to resolve
// folder paths from repository tree (which doesn't have unmanaged folders).
// When folder is empty, WriteResourceFileFromObject will use rootFolder logic:
// - For instance targets: rootFolder is empty, so fid.Path will be empty, and it will use exportPath directly
// - For folder targets: rootFolder is repo name, but fid.Path will still be empty, so it will use exportPath directly
originalFolder := meta.GetFolder()
if originalFolder != "" {
meta.SetFolder("")
defer func() {
meta.SetFolder(originalFolder)
}()
}
result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{
Path: exportPath, // Path already includes folder structure from unmanaged tree
Ref: branch,
})
if errors.Is(err, resources.ErrAlreadyInRepository) {
result.Action = repository.FileActionIgnored
} else if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("writing resource file for %s: %w", result.Name, err)
}
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
func exportResource(ctx context.Context,
resource string,
options provisioning.ExportJobOptions,
@@ -1,340 +0,0 @@
package export
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/apimachinery/utils"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
provisioningV0 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// createFolder creates a folder with the given Grafana UID as metadata.name and optional title
func createFolder(grafanaUID, k8sUID, title, parentUID string) unstructured.Unstructured {
folder := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": resources.FolderResource.GroupVersion().String(),
"kind": "Folder",
"metadata": map[string]interface{}{
"name": grafanaUID, // Grafana UID is stored as metadata.name
"uid": k8sUID,
},
"spec": map[string]interface{}{
"title": title,
},
},
}
if parentUID != "" {
meta, _ := utils.MetaAccessor(&folder)
meta.SetFolder(parentUID)
}
return folder
}
// createDashboardWithFolder creates a dashboard in the specified folder
func createDashboardWithFolder(name, folderUID string) unstructured.Unstructured {
dashboard := createDashboardObject(name)
if folderUID != "" {
meta, _ := utils.MetaAccessor(&dashboard)
meta.SetFolder(folderUID)
}
return dashboard
}
func TestExportSpecificResources(t *testing.T) {
tests := []struct {
name string
setupMocks func(t *testing.T) (resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
options provisioningV0.ExportJobOptions
wantErr string
assertResults func(t *testing.T, resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
}{
{
name: "success with folder paths",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
folder := createFolder("team-a-uid", "k8s-1", "team-a", "")
dashboard1 := createDashboardWithFolder("dashboard-1", "team-a-uid")
dashboard2 := createDashboardObject("dashboard-2")
resourceClients := resources.NewMockResourceClients(t)
folderClient := &mockDynamicInterface{items: []unstructured.Unstructured{folder}}
resourceClients.On("Folder", mock.Anything).Return(folderClient, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard1}}, resources.DashboardResource, nil).Once()
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard2}}, resources.DashboardResource, nil).Once()
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-1" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a" })).
Return("grafana/team-a/dashboard-1.json", nil)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-2" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana" })).
Return("grafana/dashboard-2.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-1" && r.Action == repository.FileActionCreated
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-2" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil).Times(2)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
Resources: []provisioningV0.ResourceRef{
{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group},
{Name: "dashboard-2", Kind: "Dashboard", Group: resources.DashboardResource.Group},
},
},
},
{
name: "empty resources returns error",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
return nil, nil, nil
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{},
},
wantErr: "no resources specified for export",
},
{
name: "rejects folders",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "my-folder" && r.Error != nil && r.Error.Error() == "folders are not supported for export"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "my-folder", Kind: "Folder", Group: resources.FolderResource.Group}},
},
},
{
name: "rejects managed resources",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
dashboard := createDashboardObject("managed-dashboard")
meta, _ := utils.MetaAccessor(&dashboard)
meta.SetManagerProperties(utils.ManagerProperties{Kind: utils.ManagerKindRepo, Identity: "some-repo"})
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "managed-dashboard" && r.Error != nil && r.Error.Error() == "resource dashboard.grafana.app/Dashboard/managed-dashboard is managed and cannot be exported"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "managed-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "rejects unsupported resources",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: "playlist.grafana.app", Kind: "Playlist"}
gvr := schema.GroupVersionResource{Group: "playlist.grafana.app", Resource: "playlists"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, gvr, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "some-resource" && r.Error != nil && r.Error.Error() == "resource type playlist.grafana.app/playlists is not supported for export"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "some-resource", Kind: "Playlist", Group: "playlist.grafana.app"}},
},
},
{
name: "resolves nested folder paths",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
parentFolder := createFolder("team-a-uid", "k8s-1", "team-a", "")
childFolder := createFolder("subteam-uid", "k8s-2", "subteam", "team-a-uid")
dashboard := createDashboardWithFolder("dashboard-in-nested-folder", "subteam-uid")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{items: []unstructured.Unstructured{parentFolder, childFolder}}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-in-nested-folder" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a/subteam" })).
Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-in-nested-folder" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-in-nested-folder", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "folder client error",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
wantErr: "get folder client: folder client error",
},
{
name: "resource not found",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, resources.DashboardResource, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "non-existent-dashboard" && r.Error != nil && r.Error.Error() == "get resource dashboard.grafana.app/Dashboard/non-existent-dashboard: no items found"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "non-existent-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "dashboard version conversion",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
v1Dashboard := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{"name": "v2-dashboard"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{"failed": true, "storedVersion": "v2alpha1"},
},
},
}
v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v1Dashboard}}, resources.DashboardResource, nil)
v2GVR := schema.GroupVersionResource{Group: resources.DashboardResource.Group, Version: "v2alpha1", Resource: resources.DashboardResource.Resource}
resourceClients.On("ForResource", mock.Anything, v2GVR).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}, gvk, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "v2-dashboard" && obj.GetAPIVersion() == "dashboard.grafana.app/v2alpha1"
}),
mock.Anything).Return("grafana/v2-dashboard.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "v2-dashboard" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "v2-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "too many errors",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
dashboard := createDashboardObject("dashboard-1")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything).Return("", fmt.Errorf("write error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-1" && r.Action == repository.FileActionIgnored && r.Error != nil
})).Return()
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors"))
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
wantErr: "too many errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resourceClients, repoResources, progress := tt.setupMocks(t)
err := ExportSpecificResources(context.Background(), "test-repo", tt.options, resourceClients, repoResources, progress)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
if tt.assertResults != nil {
tt.assertResults(t, resourceClients, repoResources, progress)
}
})
}
}
@@ -21,29 +21,26 @@ type ExportFn func(ctx context.Context, repoName string, options provisioning.Ex
type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type ExportWorker struct {
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportAllFn ExportFn
exportSpecificResourcesFn ExportFn
wrapWithStageFn WrapWithStageFn
metrics jobs.JobMetrics
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportFn ExportFn
wrapWithStageFn WrapWithStageFn
metrics jobs.JobMetrics
}
func NewExportWorker(
clientFactory resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
exportAllFn ExportFn,
exportSpecificResourcesFn ExportFn,
exportFn ExportFn,
wrapWithStageFn WrapWithStageFn,
metrics jobs.JobMetrics,
) *ExportWorker {
return &ExportWorker{
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportAllFn: exportAllFn,
exportSpecificResourcesFn: exportSpecificResourcesFn,
wrapWithStageFn: wrapWithStageFn,
metrics: metrics,
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportFn: exportFn,
wrapWithStageFn: wrapWithStageFn,
metrics: metrics,
}
}
@@ -103,19 +100,7 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return fmt.Errorf("create repository resource client: %w", err)
}
// Check if Resources list is provided (specific resources export mode)
if len(options.Resources) > 0 {
progress.SetTotal(ctx, len(options.Resources))
progress.StrictMaxErrors(1) // Fail fast on any error during export
// Validate that specific resource export is only used with folder sync targets
if cfg.Spec.Sync.Target != provisioning.SyncTargetTypeFolder {
return fmt.Errorf("specific resource export is only supported for folder sync targets, but repository has target type '%s'", cfg.Spec.Sync.Target)
}
return r.exportSpecificResourcesFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
// Fall back to existing ExportAll behavior for backward compatibility
return r.exportAllFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
err := r.wrapWithStageFn(ctx, repo, cloneOptions, fn)
@@ -56,7 +56,7 @@ func TestExportWorker_IsSupported(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewExportWorker(nil, nil, nil, nil, nil, metrics)
r := NewExportWorker(nil, nil, nil, nil, metrics)
got := r.IsSupported(context.Background(), tt.job)
require.Equal(t, tt.want, got)
})
@@ -70,7 +70,7 @@ func TestExportWorker_ProcessNoExportSettings(t *testing.T) {
},
}
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), nil, job, nil)
require.EqualError(t, err, "missing export settings")
}
@@ -93,7 +93,7 @@ func TestExportWorker_ProcessWriteNotAllowed(t *testing.T) {
},
})
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, nil)
require.EqualError(t, err, "this repository is read only")
}
@@ -117,7 +117,7 @@ func TestExportWorker_ProcessBranchNotAllowedForLocal(t *testing.T) {
},
})
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, nil)
require.EqualError(t, err, "this repository does not support the branch workflow")
}
@@ -149,7 +149,7 @@ func TestExportWorker_ProcessFailedToCreateClients(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
@@ -185,7 +185,7 @@ func TestExportWorker_ProcessNotReaderWriter(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export job submitted targeting repository that is not a ReaderWriter")
}
@@ -221,7 +221,7 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "create repository resource client: failed to create repository resources client")
}
@@ -273,7 +273,7 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -355,7 +355,7 @@ func TestExportWorker_ProcessStageOptionsWithBranch(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
})
@@ -398,7 +398,7 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
@@ -426,7 +426,7 @@ func TestExportWorker_ProcessWrapWithStageFnError(t *testing.T) {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed"))
r := NewExportWorker(nil, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "stage failed")
}
@@ -452,7 +452,7 @@ func TestExportWorker_ProcessBranchNotAllowedForStageableRepositories(t *testing
mockProgress := jobs.NewMockJobProgressRecorder(t)
// No progress messages expected in current implementation
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "this repository does not support the branch workflow")
}
@@ -504,7 +504,7 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -550,7 +550,7 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
@@ -613,7 +613,7 @@ func TestExportWorker_RefURLsSetWithBranch(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
require.NoError(t, err)
@@ -670,7 +670,7 @@ func TestExportWorker_RefURLsNotSetWithoutBranch(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
require.NoError(t, err)
@@ -727,7 +727,7 @@ func TestExportWorker_RefURLsNotSetForNonURLRepository(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
@@ -35,13 +35,12 @@ func maybeNotifyProgress(threshold time.Duration, fn ProgressFn) ProgressFn {
// FIXME: ProgressRecorder should be initialized in the queue
type JobResourceResult struct {
Name string
Group string
Kind string
Path string
Action repository.FileAction
Error error
Warning error
Name string
Group string
Kind string
Path string
Action repository.FileAction
Error error
}
type jobProgressRecorder struct {
@@ -194,10 +193,6 @@ func (r *jobProgressRecorder) updateSummary(result JobResourceResult) {
errorMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Error.Error(), result.Path, result.Name, result.Action)
summary.Errors = append(summary.Errors, errorMsg)
summary.Error++
} else if result.Warning != nil {
warningMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Warning.Error(), result.Path, result.Name, result.Action)
summary.Warnings = append(summary.Warnings, warningMsg)
summary.Warning++
} else {
switch result.Action {
case repository.FileActionDeleted:
@@ -271,17 +266,8 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
jobStatus.Message = err.Error()
}
summaries := r.summary()
jobStatus.Summary = summaries
jobStatus.Summary = r.summary()
jobStatus.Errors = r.errors
// Extract warnings from summaries
warnings := make([]string, 0)
for _, summary := range summaries {
warnings = append(warnings, summary.Warnings...)
}
jobStatus.Warnings = warnings
jobStatus.URLs = r.refURLs
tooManyErrors := r.maxErrors > 0 && r.errorCount >= r.maxErrors
@@ -297,9 +283,6 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
jobStatus.Message = "completed with errors"
jobStatus.State = provisioning.JobStateWarning
}
} else if len(jobStatus.Warnings) > 0 {
jobStatus.State = provisioning.JobStateWarning
jobStatus.Message = "completed with warnings"
}
// Override message if progress have a more explicit message
@@ -2,11 +2,9 @@ package jobs
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -85,167 +83,3 @@ func TestJobProgressRecorderCompleteIncludesRefURLs(t *testing.T) {
assert.Equal(t, provisioning.JobStateSuccess, finalStatus.State)
assert.Equal(t, "completed successfully", finalStatus.Message)
}
func TestJobProgressRecorderWarningStatus(t *testing.T) {
ctx := context.Background()
// Create a progress recorder
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
return nil
}
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
// Record a result with a warning
warningErr := errors.New("deprecated API used")
result := JobResourceResult{
Name: "test-resource",
Group: "test.grafana.app",
Kind: "Dashboard",
Path: "dashboards/test.json",
Action: repository.FileActionUpdated,
Warning: warningErr,
}
recorder.Record(ctx, result)
// Record another result with a different warning
warningErr2 := errors.New("missing optional field")
result2 := JobResourceResult{
Name: "test-resource-2",
Group: "test.grafana.app",
Kind: "Dashboard",
Path: "dashboards/test2.json",
Action: repository.FileActionCreated,
Warning: warningErr2,
}
recorder.Record(ctx, result2)
// Record a result with a warning from a different resource type
warningErr3 := errors.New("validation warning")
result3 := JobResourceResult{
Name: "test-resource-3",
Group: "test.grafana.app",
Kind: "DataSource",
Path: "datasources/test.yaml",
Action: repository.FileActionCreated,
Warning: warningErr3,
}
recorder.Record(ctx, result3)
// Verify warnings are stored in summaries
recorder.mu.RLock()
require.Len(t, recorder.summaries, 2) // Dashboard and DataSource
dashboardSummary := recorder.summaries["test.grafana.app:Dashboard"]
require.NotNil(t, dashboardSummary)
assert.Equal(t, int64(2), dashboardSummary.Warning)
assert.Len(t, dashboardSummary.Warnings, 2)
assert.Contains(t, dashboardSummary.Warnings[0], "deprecated API used")
assert.Contains(t, dashboardSummary.Warnings[1], "missing optional field")
datasourceSummary := recorder.summaries["test.grafana.app:DataSource"]
require.NotNil(t, datasourceSummary)
assert.Equal(t, int64(1), datasourceSummary.Warning)
assert.Len(t, datasourceSummary.Warnings, 1)
assert.Contains(t, datasourceSummary.Warnings[0], "validation warning")
recorder.mu.RUnlock()
// Complete the job
finalStatus := recorder.Complete(ctx, nil)
// Verify the final status includes warnings
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 3)
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
assert.Contains(t, finalStatus.Warnings[1], "missing optional field")
assert.Contains(t, finalStatus.Warnings[2], "validation warning")
// Verify the state is set to Warning
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
assert.Equal(t, "completed with warnings", finalStatus.Message)
// Verify summaries are included
require.Len(t, finalStatus.Summary, 2)
// Verify no errors were recorded
assert.Empty(t, finalStatus.Errors)
}
func TestJobProgressRecorderWarningWithErrors(t *testing.T) {
ctx := context.Background()
// Create a progress recorder
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
return nil
}
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
// Record a result with an error (errors take precedence)
errorErr := errors.New("failed to process")
result := JobResourceResult{
Name: "test-resource",
Group: "test.grafana.app",
Kind: "Dashboard",
Path: "dashboards/test.json",
Action: repository.FileActionUpdated,
Error: errorErr,
}
recorder.Record(ctx, result)
// Record a result with only a warning
warningErr := errors.New("deprecated API used")
result2 := JobResourceResult{
Name: "test-resource-2",
Group: "test.grafana.app",
Kind: "Dashboard",
Path: "dashboards/test2.json",
Action: repository.FileActionCreated,
Warning: warningErr,
}
recorder.Record(ctx, result2)
// Complete the job
finalStatus := recorder.Complete(ctx, nil)
// When there are errors, the state should be Warning (not Error unless too many)
// and warnings should still be included
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
assert.Equal(t, "completed with errors", finalStatus.Message)
assert.Len(t, finalStatus.Errors, 1)
assert.Contains(t, finalStatus.Errors[0], "failed to process")
// Warnings should still be extracted from summaries
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 1)
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
}
func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) {
ctx := context.Background()
// Create a progress recorder
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
return nil
}
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
// Record only warnings, no errors
warningErr := errors.New("deprecated API used")
result := JobResourceResult{
Name: "test-resource",
Group: "test.grafana.app",
Kind: "Dashboard",
Path: "dashboards/test.json",
Action: repository.FileActionUpdated,
Warning: warningErr,
}
recorder.Record(ctx, result)
// Complete the job
finalStatus := recorder.Complete(ctx, nil)
// Verify the state is Warning (not Error) when only warnings exist
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
assert.Equal(t, "completed with warnings", finalStatus.Message)
assert.Empty(t, finalStatus.Errors)
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 1)
}
@@ -705,7 +705,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.clients,
b.repositoryResources,
export.ExportAll,
export.ExportSpecificResources,
stageIfPossible,
metrics,
)
@@ -167,40 +167,30 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
title = name
}
fileName := slugify.Slugify(title) + ".json"
folder := meta.GetFolder()
// Get the absolute path of the folder
rootFolder := RootFolder(r.repo.Config())
// Build the full path: start with options.Path, then add folder path, then filename
basePath := options.Path
// If options.Path is provided, use it directly (it already includes folder structure from export).
// Otherwise, resolve folder path from the repository tree.
if basePath == "" {
folder := meta.GetFolder()
// Get the absolute path of the folder
rootFolder := RootFolder(r.repo.Config())
if folder == "" {
// If no folder is specified and no path is provided, set it to the root to ensure everything is written under it
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
} else {
var ok bool
var fid Folder
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
if !ok {
// Fallback: try without rootFolder (for instance targets where rootFolder is empty)
fid, ok = r.folders.Tree().DirPath(folder, "")
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree", folder)
}
}
if fid.Path != "" {
basePath = fid.Path
}
// If no folder is specified in the file, set it to the root to ensure everything is written under it
var fid Folder
if folder == "" {
fid = Folder{ID: rootFolder}
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
} else {
var ok bool
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder)
}
}
if basePath != "" {
fileName = safepath.Join(basePath, fileName)
fileName := slugify.Slugify(title) + ".json"
if fid.Path != "" {
fileName = safepath.Join(fid.Path, fileName)
}
if options.Path != "" {
fileName = safepath.Join(options.Path, fileName)
}
parsed := ParsedResource{
@@ -145,8 +145,6 @@ func (t *folderTree) AddUnstructured(item *unstructured.Unstructured) error {
return fmt.Errorf("extract meta accessor: %w", err)
}
// In Grafana, folder UIDs are stored as metadata.name
// The grafana.app/folder annotation contains the folder's metadata.name (which is its Grafana UID)
folder := Folder{
Title: meta.FindTitle(item.GetName()),
ID: item.GetName(),
-1
View File
@@ -38,7 +38,6 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui
}
func (b *ServiceAPIBuilder) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
-5
View File
@@ -3,7 +3,6 @@ package apiregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/registry/apis/collections"
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
@@ -34,10 +33,6 @@ var WireSetExts = wire.NewSet(
externalgroupmapping.ProvideNoopTeamGroupsREST,
wire.Bind(new(externalgroupmapping.TeamGroupsHandler), new(*externalgroupmapping.NoopTeamGroupsREST)),
// Auditing Options
auditing.ProvideNoopBackend,
auditing.ProvideNoopPolicyRuleEvaluator,
)
var provisioningExtras = wire.NewSet(
@@ -5,7 +5,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
@@ -17,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -62,11 +60,6 @@ func RegisterAppInstaller(
return installer, nil
}
func (a *AppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
func (a *AppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) rest.Storage {
kind := correlationsV0.CorrelationKind()
gvr := schema.GroupVersionResource{
-8
View File
@@ -6,20 +6,17 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
"github.com/grafana/grafana/apps/playlist/pkg/apis"
playlistv0alpha1 "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
playlistapp "github.com/grafana/grafana/apps/playlist/pkg/app"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
@@ -66,11 +63,6 @@ func RegisterAppInstaller(
return installer, nil
}
func (p *PlaylistAppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
// GetLegacyStorage returns the legacy storage for the playlist app.
func (p *PlaylistAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage {
gvr := playlistv0alpha1.PlaylistKind().GroupVersionResource()
-7
View File
@@ -3,14 +3,12 @@ package quotas
import (
"github.com/grafana/grafana/apps/quotas/pkg/apis"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
quotasapp "github.com/grafana/grafana/apps/quotas/pkg/app"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
@@ -24,11 +22,6 @@ type QuotasAppInstaller struct {
cfg *setting.Cfg
}
func (a *QuotasAppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
func RegisterAppInstaller(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
+2 -7
View File
@@ -14,7 +14,6 @@ import (
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/grafana/grafana/pkg/expr"
@@ -832,9 +831,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
backend := auditing.ProvideNoopBackend()
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
return nil, err
}
@@ -1492,9 +1489,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
backend := auditing.ProvideNoopBackend()
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
return nil, err
}
@@ -114,8 +114,6 @@ func RegisterAuthorizers(
registrar.Register(gv, authorizer)
logger.Debug("Registered authorizer", "group", gv.Group, "version", gv.Version, "app")
}
} else {
panic("authorizer cannot be nil for api group: " + installer.GroupVersions()[0].Group)
}
}
}
@@ -15,7 +15,6 @@ func TestRegisterAuthorizers(t *testing.T) {
name string
appInstallers []appsdkapiserver.AppInstaller
expectedRegisters int
expectedPanic bool
}{
{
name: "empty installers list",
@@ -31,7 +30,7 @@ func TestRegisterAuthorizers(t *testing.T) {
},
},
},
expectedPanic: true,
expectedRegisters: 0,
},
{
name: "single installer with authorizer provider",
@@ -47,20 +46,6 @@ func TestRegisterAuthorizers(t *testing.T) {
},
expectedRegisters: 1,
},
{
name: "single installer with invalid authorizer provider",
appInstallers: []appsdkapiserver.AppInstaller{
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
groupVersions: []schema.GroupVersion{
{Group: "test.example.com", Version: "v1"},
},
},
mockAuthorizer: nil,
},
},
expectedPanic: true,
},
{
name: "installer with multiple group versions",
appInstallers: []appsdkapiserver.AppInstaller{
@@ -78,7 +63,7 @@ func TestRegisterAuthorizers(t *testing.T) {
expectedRegisters: 3,
},
{
name: "multiple installers with authorizer support",
name: "multiple installers with mixed authorizer support",
appInstallers: []appsdkapiserver.AppInstaller{
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
@@ -88,6 +73,11 @@ func TestRegisterAuthorizers(t *testing.T) {
},
mockAuthorizer: &mockAuthorizer{},
},
&mockAppInstaller{
groupVersions: []schema.GroupVersion{
{Group: "other.example.com", Version: "v1"},
},
},
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
groupVersions: []schema.GroupVersion{
@@ -98,7 +88,7 @@ func TestRegisterAuthorizers(t *testing.T) {
mockAuthorizer: &mockAuthorizer{},
},
},
expectedRegisters: 3, // 1 from first installer + 2 from second installer
expectedRegisters: 3, // 1 from first installer + 2 from third installer
},
}
@@ -106,13 +96,6 @@ func TestRegisterAuthorizers(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
registrar := &mockAuthorizerRegistrar{}
if tt.expectedPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("%s case did not panic as expected", t.Name())
}
}()
}
RegisterAuthorizers(ctx, tt.appInstallers, registrar)
require.Equal(t, tt.expectedRegisters, len(registrar.registrations))
})
@@ -38,12 +38,12 @@ func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
// Individual services may have explicit implementations
apis := make(map[string]authorizer.Authorizer)
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
authorizers = append(authorizers, &authorizerForAPI{apis})
// org role authorizer is last -- and will return allow for verbs that match expectations
// it is only helpful here for remote APIs in some cloud use-cases.
//nolint:staticcheck // remove once build handler chains are untangled between local and remote APIs handling
// org role is last -- and will return allow for verbs that match expectations
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
// NOTE: role authorizer is now used by some api groups as their specific authorizer
// but there are still some apis not directly registered in the embedded delegate that benefit from including it here
authorizers = append(authorizers, NewRoleAuthorizer())
return &GrafanaAuthorizer{
apis: apis,
@@ -19,7 +19,6 @@ var orgRoleNoneAsViewerAPIGroups = []string{
type roleAuthorizer struct{}
// Deprecated: NewRoleAuthorizer exists for apps that were launched with simplistic authorization requirements. Consider using NewResourceAuthorizer instead.
func NewRoleAuthorizer() *roleAuthorizer {
return &roleAuthorizer{}
}
-12
View File
@@ -12,7 +12,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/audit"
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
"k8s.io/apiserver/pkg/endpoints/responsewriter"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -114,9 +113,6 @@ type service struct {
appInstallers []appsdkapiserver.AppInstaller
builderMetrics *builder.BuilderMetrics
dualWriterMetrics *grafanarest.DualWriterMetrics
auditBackend audit.Backend
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator
}
func ProvideService(
@@ -141,8 +137,6 @@ func ProvideService(
aggregatorRunner aggregatorrunner.AggregatorRunner,
appInstallers []appsdkapiserver.AppInstaller,
builderMetrics *builder.BuilderMetrics,
auditBackend audit.Backend,
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator,
) (*service, error) {
scheme := builder.ProvideScheme()
codecs := builder.ProvideCodecFactory(scheme)
@@ -173,8 +167,6 @@ func ProvideService(
appInstallers: appInstallers,
builderMetrics: builderMetrics,
dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg),
auditBackend: auditBackend,
auditPolicyRuleEvaluator: auditPolicyRuleEvaluator,
}
// This will be used when running as a dskit service
s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer)
@@ -363,10 +355,6 @@ func (s *service) start(ctx context.Context) error {
appinstaller.BuildOpenAPIDefGetter(s.appInstallers),
}
// Auditing Options
serverConfig.AuditBackend = s.auditBackend
serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleEvaluator
// Add OpenAPI specs for each group+version (existing builders)
err = builder.SetupConfig(
s.scheme,
+1 -31
View File
@@ -4,12 +4,8 @@ import (
"google.golang.org/protobuf/types/known/structpb"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/accesscontrol"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
@@ -48,8 +44,7 @@ func getTypeInfo(group, resource string) (typeInfo, bool) {
func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo {
typ, relations := getTypeAndRelations(r.GetGroup(), r.GetResource())
resource := newResource(
return newResource(
typ,
r.GetGroup(),
r.GetResource(),
@@ -58,19 +53,6 @@ func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo {
r.GetSubresource(),
relations,
)
// Special case for creating folders and resources in the root folder
if r.GetVerb() == utils.VerbCreate {
if resource.IsFolderResource() && resource.name == "" {
resource.name = accesscontrol.GeneralFolderUID
} else if resource.HasFolderSupport() && resource.folder == "" {
resource.folder = accesscontrol.GeneralFolderUID
}
return resource
}
return resource
}
func NewResourceInfoFromBatchItem(i *authzextv1.BatchCheckItem) ResourceInfo {
@@ -182,15 +164,3 @@ func (r ResourceInfo) IsValidRelation(relation string) bool {
func (r ResourceInfo) HasSubresource() bool {
return r.subresource != ""
}
var resourcesWithFolderSupport = map[string]bool{
dashboardV1.DashboardResourceInfo.GroupResource().Group: true,
}
func (r ResourceInfo) HasFolderSupport() bool {
return resourcesWithFolderSupport[r.group]
}
func (r ResourceInfo) IsFolderResource() bool {
return r.group == folders.FolderResourceInfo.GroupResource().Group
}
@@ -228,9 +228,6 @@ func TranslateToResourceTuple(subject string, action, kind, name string) (*openf
}
if name == "*" {
if m.group != "" && m.resource != "" {
return NewGroupResourceTuple(subject, m.relation, m.group, m.resource, m.subresource), true
}
return NewGroupResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource), true
}
@@ -1,89 +0,0 @@
package common
import (
"testing"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
type translationTestCase struct {
testName string
subject string
action string
kind string
name string
expected *openfgav1.TupleKey
}
func TestTranslateToResourceTuple(t *testing.T) {
tests := []translationTestCase{
{
testName: "dashboards:read in folders",
subject: "user:1",
action: "dashboards:read",
kind: "folders",
name: "*",
expected: &openfgav1.TupleKey{
User: "user:1",
Relation: "get",
Object: "group_resource:dashboard.grafana.app/dashboards",
},
},
{
testName: "dashboards:read for all dashboards",
subject: "user:1",
action: "dashboards:read",
kind: "dashboards",
name: "*",
expected: &openfgav1.TupleKey{
User: "user:1",
Relation: "get",
Object: "group_resource:dashboard.grafana.app/dashboards",
},
},
{
testName: "dashboards:read for general folder",
subject: "user:1",
action: "dashboards:read",
kind: "folders",
name: "general",
expected: &openfgav1.TupleKey{
User: "user:1",
Relation: "resource_get",
Object: "folder:general",
Condition: &openfgav1.RelationshipCondition{
Name: "subresource_filter",
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"subresources": structpb.NewListValue(&structpb.ListValue{
Values: []*structpb.Value{structpb.NewStringValue("dashboard.grafana.app/dashboards")},
}),
},
},
},
},
},
{
testName: "folders:read",
subject: "user:1",
action: "folders:read",
kind: "folders",
name: "*",
expected: &openfgav1.TupleKey{
User: "user:1",
Relation: "get",
Object: "group_resource:folder.grafana.app/folders",
},
},
}
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
tuple, ok := TranslateToResourceTuple(test.subject, test.action, test.kind, test.name)
require.True(t, ok)
require.EqualExportedValues(t, test.expected, tuple)
})
}
}
@@ -212,16 +212,4 @@ func testCheck(t *testing.T, server *Server) {
require.NoError(t, err)
assert.True(t, res.GetAllowed(), "user should be able to view dashboards in folder 6")
})
t.Run("user:18 should be able to create folder in root folder", func(t *testing.T) {
res, err := server.Check(newContextWithNamespace(), newReq("user:18", utils.VerbCreate, folderGroup, folderResource, "", "", ""))
require.NoError(t, err)
assert.Equal(t, true, res.GetAllowed())
})
t.Run("user:18 should be able to create dashboard in root folder", func(t *testing.T) {
res, err := server.Check(newContextWithNamespace(), newReq("user:18", utils.VerbCreate, dashboardGroup, dashboardResource, "", "", ""))
require.NoError(t, err)
assert.Equal(t, true, res.GetAllowed())
})
}
@@ -71,8 +71,6 @@ func setup(t *testing.T, srv *Server) *Server {
common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"),
common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"),
common.NewFolderTuple("user:17", common.RelationSetView, "4"),
common.NewFolderTuple("user:18", common.RelationCreate, "general"),
common.NewFolderResourceTuple("user:18", common.RelationCreate, dashboardGroup, dashboardResource, "", "general"),
}
return setupOpenFGADatabase(t, srv, tuples)
+2 -11
View File
@@ -412,16 +412,11 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro
deletePermanently = true
}
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
namespace := ngmodels.NewNamespace(f)
if err := namespace.ValidateForRuleStorage(); err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
}
if err := srv.checkGroupLimits(ruleGroupConfig); err != nil {
return ErrResp(http.StatusBadRequest, err, "")
}
@@ -846,14 +841,10 @@ func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body a
return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "")
}
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
namespace := ngmodels.NewNamespace(f)
if err := namespace.ValidateForRuleStorage(); err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
}
ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{
User: c.SignedInUser,
@@ -18,7 +18,6 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -1289,64 +1288,4 @@ func TestRouteUpdateNamespaceRules(t *testing.T) {
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
t.Run("should reject update when folder is managed by ManagerKindRepo", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
// Create a managed folder
managedFolder := randFolder()
managedFolder.ManagedBy = utils.ManagerKindRepo
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
// Create some rules in the managed folder
ruleGen := models.RuleGen.With(
models.RuleGen.WithOrgID(orgID),
models.RuleGen.WithNamespaceUID(managedFolder.UID),
)
rules := ruleGen.GenerateManyRef(2)
ruleStore.PutRule(context.Background(), rules...)
permissions := createPermissionsForRules(rules, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, managedFolder.UID)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
// Verify no rules were updated
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
}
func TestRoutePostNameRulesConfig(t *testing.T) {
t.Run("should reject creation when folder is managed by ManagerKindRepo", func(t *testing.T) {
orgID := rand.Int63()
ruleStore := fakes.NewRuleStore(t)
// Create a managed folder
managedFolder := randFolder()
managedFolder.ManagedBy = utils.ManagerKindRepo
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
permissions := map[int64]map[string][]string{
orgID: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(managedFolder.UID): {dashboards.ActionFoldersRead},
},
}
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createService(ruleStore, nil)
response := svc.RoutePostNameRulesConfig(requestCtx, apimodels.PostableRuleGroupConfig{
Name: "test-group",
}, managedFolder.UID)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
})
}
@@ -296,7 +296,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
allowedNamespaces := map[string]string{}
for namespaceUID, folder := range namespaceMap {
// only add namespaces that the user has access to rules in
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.NewNamespace(folder))
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.Namespace(*folder.ToFolderReference()))
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error())
+1 -1
View File
@@ -204,7 +204,7 @@ func IsNonRetryableError(err error) bool {
return false
}
// IsError returns true when Results contains at least one element and all elements are errors
// HasErrors returns true when Results contains at least one element and all elements are errors
func (evalResults Results) IsError() bool {
for _, r := range evalResults {
if r.State != Error {
-15
View File
@@ -24,7 +24,6 @@ import (
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/setting"
@@ -398,20 +397,6 @@ type Namespaced interface {
type Namespace folder.FolderReference
func NewNamespace(f *folder.Folder) Namespace {
return Namespace(*f.ToFolderReference())
}
func (n Namespace) ValidateForRuleStorage() error {
if n.UID == "" {
return fmt.Errorf("cannot store rules in folder without UID")
}
if n.ManagedBy == utils.ManagerKindRepo {
return fmt.Errorf("cannot store rules in folder managed by Git Sync")
}
return nil
}
func (n Namespace) GetNamespaceUID() string {
return n.UID
}
@@ -114,7 +114,7 @@ func (service *AlertRuleService) ListAlertRules(ctx context.Context, user identi
}
folderUIDs := make([]string, 0, len(folders))
for _, f := range folders {
access, err := service.authz.HasAccessInFolder(ctx, user, models.NewNamespace(f))
access, err := service.authz.HasAccessInFolder(ctx, user, models.Namespace(*f.ToFolderReference()))
if err != nil {
return nil, nil, "", err
}
@@ -407,9 +407,6 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil {
return err
}
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), namespaceUID); err != nil {
return err
}
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
query := &models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
@@ -474,10 +471,6 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
return err
}
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), group.FolderUID); err != nil {
return err
}
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 {
return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title)
@@ -1032,7 +1025,6 @@ func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) e
// ensureNamespace ensures that the rule has a valid namespace UID.
// If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error.
// If the folder is managed by a manager, it will also return an error.
func (service *AlertRuleService) ensureNamespace(ctx context.Context, user identity.Requester, orgID int64, namespaceUID string) error {
if namespaceUID == "" {
return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation)
@@ -1045,23 +1037,18 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident
}
// ensure the namespace exists
f, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
_, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
OrgID: orgID,
UID: &namespaceUID,
SignedInUser: user,
})
if err != nil || f == nil {
if err != nil {
if errors.Is(err, dashboards.ErrFolderNotFound) {
return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation)
}
return err
}
// check if the folder is managed by a manager
if err := models.NewNamespace(f).ValidateForRuleStorage(); err != nil {
return fmt.Errorf("%w: %s", models.ErrAlertRuleFailedValidation, err)
}
return nil
}
@@ -16,7 +16,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/db"
@@ -868,27 +867,6 @@ func TestIntegrationAlertRuleService(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(120), rule.IntervalSeconds)
})
t.Run("UpdateRuleGroup should reject when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-update-group"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
err := service.UpdateRuleGroup(context.Background(), u, managedFolderUID, "some-group", 120)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestIntegrationCreateAlertRule(t *testing.T) {
@@ -1188,30 +1166,6 @@ func TestIntegrationCreateAlertRule(t *testing.T) {
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule")
})
t.Run("should reject creation when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
rule := dummyRule("test-managed-folder", orgID)
rule.NamespaceUID = managedFolderUID
_, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestUpdateAlertRule(t *testing.T) {
@@ -1362,36 +1316,6 @@ func TestUpdateAlertRule(t *testing.T) {
require.Equal(t, "nogroup-update-new", updated.Title)
require.Equal(t, originalInterval, updated.IntervalSeconds)
})
t.Run("should reject update when folder is managed by a manager", func(t *testing.T) {
service, ruleStore, provenanceStore, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-update"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
// Create an existing rule
existingRule := dummyRule("test-managed-folder-update", orgID)
existingRule.NamespaceUID = managedFolderUID
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.InsertRule{{AlertRule: existingRule}})
require.NoError(t, err)
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &existingRule, orgID, models.ProvenanceNone))
// Try to update the rule
existingRule.Title = "Updated Title"
_, err = service.UpdateAlertRule(context.Background(), u, existingRule, models.ProvenanceNone)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestDeleteAlertRule(t *testing.T) {
@@ -2130,33 +2054,6 @@ func TestReplaceGroup(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "cannot move rule out of this group")
})
t.Run("should reject replace when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-replace"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
group := models.AlertRuleGroup{
Title: "test-group",
FolderUID: managedFolderUID,
Interval: 60,
}
err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone, "")
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestDeleteRuleGroup(t *testing.T) {
+1 -1
View File
@@ -530,7 +530,7 @@ func (h *RemoteLokiBackend) getFolderUIDsForFilter(ctx context.Context, query mo
uids := make([]string, 0, len(folders))
// now keep only UIDs of folder in which user can read rules.
for _, f := range folders {
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.NewNamespace(f))
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.Namespace(*f.ToFolderReference()))
if err != nil {
return nil, err
}
@@ -261,8 +261,8 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
logger.Info("Starting batched dashboard_uid migration for annotations (newest first)", "batchSize", batchSize)
updateSQL := `UPDATE annotation
SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
AND annotation.id IN (
SELECT id FROM annotation
@@ -285,19 +285,19 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
LIMIT $1
)`
case MySQL:
updateSQL = `UPDATE annotation AS a
JOIN dashboard AS d ON a.dashboard_id = d.id
JOIN (
SELECT id
FROM annotation
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
ORDER BY id DESC
LIMIT ?
) AS batch ON batch.id = a.id
SET a.dashboard_uid = d.uid
WHERE a.dashboard_uid IS NULL
AND a.dashboard_id != 0`
updateSQL = `UPDATE annotation
INNER JOIN dashboard ON annotation.dashboard_id = dashboard.id
SET annotation.dashboard_uid = dashboard.uid
WHERE annotation.dashboard_uid IS NULL
AND annotation.dashboard_id != 0
AND annotation.id IN (
SELECT id FROM (
SELECT id FROM annotation
WHERE dashboard_uid IS NULL AND dashboard_id != 0
ORDER BY id DESC
LIMIT ?
) AS batch
)`
}
updatedTotal := int64(0)
@@ -3288,18 +3288,6 @@
"path": {
"description": "FIXME: we should validate this in admission hooks Prefix in target file system",
"type": "string"
},
"resources": {
"description": "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceRef"
}
]
}
}
}
},
@@ -3708,7 +3696,7 @@
"format": "int64"
},
"errors": {
"description": "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
"description": "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
"type": "array",
"items": {
"type": "string",
@@ -3734,18 +3722,6 @@
"type": "integer",
"format": "int64"
},
"warning": {
"description": "The error count",
"type": "integer",
"format": "int64"
},
"warnings": {
"type": "array",
"items": {
"type": "string",
"default": ""
}
},
"write": {
"type": "integer",
"format": "int64"
@@ -3873,13 +3849,6 @@
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs"
}
]
},
"warnings": {
"type": "array",
"items": {
"type": "string",
"default": ""
}
}
}
},
@@ -1,390 +0,0 @@
package provisioning
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationProvisioning_ExportSpecificResources(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create unmanaged dashboards directly in Grafana
dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create first dashboard")
dashboard1Name := dashboard1Obj.GetName()
dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml")
dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create second dashboard")
dashboard2Name := dashboard2Obj.GetName()
// Verify dashboards are unmanaged
dash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{})
require.NoError(t, err)
manager1, found1 := dash1.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found1 || manager1 == "", "dashboard1 should be unmanaged")
dash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{})
require.NoError(t, err)
manager2, found2 := dash2.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found2 || manager2 == "", "dashboard2 should be unmanaged")
// Create repository with folder sync target (required for specific resource export)
const repo = "export-resources-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0, // No dashboards expected after sync (we'll export manually)
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created dashboards before repo
}
helper.CreateRepo(t, testRepo)
// Export specific dashboards using Resources field
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "",
Resources: []provisioning.ResourceRef{
{
Name: dashboard1Name,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: dashboard2Name,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// Verify both dashboards were exported
dashboard1File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
dashboard2File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v2beta1.json")
// Check dashboard1
body1, err := os.ReadFile(dashboard1File) //nolint:gosec
require.NoError(t, err, "exported file should exist for dashboard1")
obj1 := map[string]any{}
err = json.Unmarshal(body1, &obj1)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj1, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
// Check dashboard2
body2, err := os.ReadFile(dashboard2File) //nolint:gosec
require.NoError(t, err, "exported file should exist for dashboard2")
obj2 := map[string]any{}
err = json.Unmarshal(body2, &obj2)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err = unstructured.NestedString(obj2, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v2beta1", val)
}
func TestIntegrationProvisioning_ExportSpecificResourcesWithPath(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create unmanaged dashboard
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create dashboard")
dashboardName := dashboardObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-resources-path-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created dashboard before repo
}
helper.CreateRepo(t, testRepo)
// Export with custom path
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "custom/path",
Resources: []provisioning.ResourceRef{
{
Name: dashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// Verify dashboard was exported to custom path
expectedFile := filepath.Join(helper.ProvisioningPath, "custom", "path", "test-dashboard-created-at-v1.json")
body, err := os.ReadFile(expectedFile) //nolint:gosec
require.NoError(t, err, "exported file should exist at custom path")
obj := map[string]any{}
err = json.Unmarshal(body, &obj)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
}
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsFolders(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create a folder
folder := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "folder.grafana.app/v1beta1",
"kind": "Folder",
"metadata": map[string]any{
"name": "test-folder",
},
"spec": map[string]any{
"title": "Test Folder",
},
},
}
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create folder")
folderName := folderObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-reject-folders-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created folder before repo
}
helper.CreateRepo(t, testRepo)
// Try to export folder (should fail validation)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: folderName,
Kind: "Folder",
Group: "folder.grafana.app",
},
},
},
}
// This should fail with validation error
body := asJSON(spec)
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx)
err = result.Error()
require.Error(t, err, "should fail validation when trying to export folder")
require.Contains(t, err.Error(), "folders are not supported", "error should mention folders are not supported")
}
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsManagedResources(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create a managed dashboard via repository sync (use folder target to allow second repo)
testRepo := TestRepo{
Name: "managed-dashboard-repo",
Target: "folder",
Copies: map[string]string{
"exportunifiedtorepository/dashboard-test-v1.yaml": "dashboard.json",
},
ExpectedDashboards: 1,
ExpectedFolders: 1, // Folder target creates a folder with the repo name
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
}
helper.CreateRepo(t, testRepo)
// Get the managed dashboard
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, dashboards.Items, 1, "should have one managed dashboard")
managedDashboard := dashboards.Items[0]
managedDashboardName := managedDashboard.GetName()
// Verify it's managed
manager, found := managedDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, found && manager != "", "dashboard should be managed")
// Create another repository for export (must be folder target since instance can only exist alone)
const exportRepo = "export-managed-reject-test-repo"
exportTestRepo := TestRepo{
Name: exportRepo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
}
helper.CreateRepo(t, exportTestRepo)
// Try to export managed dashboard (should fail)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: managedDashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
// This should fail because the resource is managed
body := asJSON(spec)
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(exportRepo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx)
// Wait for job to complete and check it failed
obj, err := result.Get()
require.NoError(t, err, "job should be created")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "should get unstructured object")
// Wait for job to complete
job := helper.AwaitJob(t, ctx, unstruct)
lastState := mustNestedString(job.Object, "status", "state")
lastErrors := mustNestedStringSlice(job.Object, "status", "errors")
// Job should fail with error about managed resource
require.Equal(t, string(provisioning.JobStateError), lastState, "job should fail")
require.NotEmpty(t, lastErrors, "job should have errors")
require.Contains(t, lastErrors[0], "managed", "error should mention managed resource")
}
func TestIntegrationProvisioning_ExportSpecificResourcesWithFolderStructure(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create an unmanaged folder
folder := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "folder.grafana.app/v1beta1",
"kind": "Folder",
"metadata": map[string]any{
"name": "test-export-folder",
},
"spec": map[string]any{
"title": "Test Export Folder",
},
},
}
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create folder")
folderUID := folderObj.GetUID()
// Verify folder is unmanaged
manager, found := folderObj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found || manager == "", "folder should be unmanaged")
// Create unmanaged dashboard in the folder
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
// Set folder UID in dashboard spec
err = unstructured.SetNestedField(dashboard.Object, string(folderUID), "spec", "folder")
require.NoError(t, err, "should be able to set folder UID")
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create dashboard in folder")
dashboardName := dashboardObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-folder-structure-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created folder and dashboard before repo
}
helper.CreateRepo(t, testRepo)
// Export dashboard (should preserve folder structure)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "",
Resources: []provisioning.ResourceRef{
{
Name: dashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// For folder sync targets with specific resource export, the folder structure
// from unmanaged folders should be preserved in the export path
// Expected: <provisioning_path>/<folder_name>/<dashboard>.json
expectedFile := filepath.Join(helper.ProvisioningPath, "Test Export Folder", "test-dashboard-created-at-v1.json")
body, err := os.ReadFile(expectedFile) //nolint:gosec
if err != nil {
// Fallback: if folder structure not preserved, file might be at root
expectedFile = filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
body, err = os.ReadFile(expectedFile) //nolint:gosec
require.NoError(t, err, "exported file should exist (either with folder structure or at root)")
t.Logf("Note: Dashboard exported to root instead of preserving folder structure")
}
obj := map[string]any{}
err = json.Unmarshal(body, &obj)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
}
@@ -28,7 +28,23 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }
const interactiveLearningPluginId = getInteractiveLearningPluginId(availableComponents);
if (isSmallScreen || !enrichedHelpNode.hideFromTabs || interactiveLearningPluginId === undefined) {
// Check if the component is actually registered, not just if the plugin exists
// This allows plugins to conditionally register their sidebar component (e.g., for A/B testing)
const componentId = interactiveLearningPluginId
? getComponentIdFromComponentMeta(interactiveLearningPluginId, 'Interactive learning')
: undefined;
// Show native help dropdown if:
// - Screen is small (mobile/responsive), OR
// - hideFromTabs is false, OR
// - Interactive learning plugin is not installed, OR
// - Interactive learning component is not registered (plugin may exist but chose not to register)
if (
isSmallScreen ||
!enrichedHelpNode.hideFromTabs ||
interactiveLearningPluginId === undefined ||
componentId === undefined
) {
return (
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
<ToolbarButton
@@ -41,7 +57,6 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }
);
}
const componentId = getComponentIdFromComponentMeta(interactiveLearningPluginId, 'Interactive learning');
const isOpen = dockedComponentId === componentId;
return (
@@ -1,4 +1,5 @@
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
// Because the navigation content is dynamic (defined in the backend), we can not use
// the normal inline message definition method.
@@ -48,7 +49,9 @@ export function getNavTitle(navId: string | undefined) {
case 'dashboards/recently-deleted':
return t('nav.recently-deleted.title', 'Recently deleted');
case 'dashboards/new':
return t('nav.new-dashboard.title', 'New dashboard');
return config.featureToggles.dashboardTemplates
? t('nav.new-dashboard.empty-title', 'Empty dashboard')
: t('nav.new-dashboard.title', 'New dashboard');
case 'dashboards/folder/new':
return t('nav.new-folder.title', 'New folder');
case 'dashboards/import':
@@ -27,7 +27,6 @@ import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
import CreateNewButton from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { RecentlyViewedDashboards } from './components/RecentlyViewedDashboards';
import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
import { useHasSelection } from './state/hooks';
@@ -179,8 +178,6 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
>
<Page.Contents className={styles.pageContents}>
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
{/* only show recently viewed dashboards when in root */}
{!folderUID && <RecentlyViewedDashboards />}
<div>
<FilterInput
placeholder={getSearchPlaceholder(searchState.includePanels)}
@@ -1,77 +0,0 @@
import { css } from '@emotion/css';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { CollapsableSection, Link, Spinner, Text, useStyles2 } from '@grafana/ui';
import { getRecentlyViewedDashboards } from './utils';
const MAX_RECENT = 5;
export function RecentlyViewedDashboards() {
const styles = useStyles2(getStyles);
const { value: recentDashboards = [], loading } = useAsync(async () => {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return [];
}
return getRecentlyViewedDashboards(MAX_RECENT);
}, []);
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return null;
}
return (
<CollapsableSection
headerDataTestId="browseDashboardsRecentlyViewedTitle"
label={
<Text variant="h5" element="h3">
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
</Text>
}
isOpen={true}
className={styles.title}
contentClassName={styles.content}
>
{/* placeholder */}
{loading && <Spinner />}
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
{!loading && recentDashboards.length === 0 && (
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
)}
{/* TODO: implement actual card content */}
{!loading && recentDashboards.length > 0 && (
<>
{recentDashboards.map((dash) => (
<div key={dash.uid}>
<Link href={dash.url}>{dash.name}</Link>
</div>
))}
</>
)}
</CollapsableSection>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const accent = theme.visualization.getColorByName('purple'); // or your own hex
return {
title: css({
background: `linear-gradient(90deg, ${accent} 0%, #e478eaff 100%)`,
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
color: 'transparent',
'& button svg': {
color: accent,
},
}),
content: css({
paddingTop: theme.spacing(0),
}),
};
};
@@ -1,9 +1,6 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
@@ -63,36 +60,3 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) {
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
return Boolean(canSelectFolders || canSelectDashboards);
}
/**
* Returns dashboard search results ordered the same way the user opened them.
*/
export async function getRecentlyViewedDashboards(maxItems = 5): Promise<DashboardQueryResult[]> {
try {
const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems);
if (!recentlyOpened.length) {
return [];
}
const searchResults = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: recentlyOpened.length,
uid: recentlyOpened,
});
const dashboards = searchResults.view.toArray();
// Keep dashboards in the same order the user opened them.
// When a UID is missing from the search response
// push it to the end instead of letting indexOf return -1
const order = (uid: string) => {
const idx = recentlyOpened.indexOf(uid);
return idx === -1 ? recentlyOpened.length : idx;
};
dashboards.sort((a, b) => order(a.uid) - order(b.uid));
return dashboards;
} catch (error) {
console.error('Failed to load recently viewed dashboards', error);
return [];
}
}
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getRecentlyViewedDashboards } from 'app/features/browse-dashboards/components/utils';
import impressionSrv from 'app/core/services/impression_srv';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { CommandPaletteAction } from '../types';
@@ -20,7 +20,20 @@ export async function getRecentDashboardActions(): Promise<CommandPaletteAction[
return [];
}
const recentResults = await getRecentlyViewedDashboards(MAX_RECENT_DASHBOARDS);
const recentUids = (await impressionSrv.getDashboardOpened()).slice(0, MAX_RECENT_DASHBOARDS);
const resultsDataFrame = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: MAX_RECENT_DASHBOARDS,
uid: recentUids,
});
// Search results are alphabetical, so reorder them according to recently viewed
const recentResults = resultsDataFrame.view.toArray();
recentResults.sort((resultA, resultB) => {
const orderA = recentUids.indexOf(resultA.uid);
const orderB = recentUids.indexOf(resultB.uid);
return orderA - orderB;
});
const recentDashboardActions: CommandPaletteAction[] = recentResults.map((item) => {
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
@@ -206,10 +206,6 @@ export class DashboardSceneChangeTracker {
}
this._changesWorker!.onmessage = (e: MessageEvent<DashboardChangeInfo>) => {
if (!this._dashboard.state.isEditing) {
return;
}
this.updateIsDirty(!!e.data.hasChanges);
};
@@ -171,6 +171,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<DashboardControlActions dashboard={dashboard} />
</div>
)}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
</div>
{!hideVariableControls && (
<>
@@ -178,7 +179,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<DashboardDataLayerControls dashboard={dashboard} />
</>
)}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
{!hideDashboardControls && hasDashboardControls && <DashboardControlsButton dashboard={dashboard} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
@@ -64,6 +64,7 @@ function getStyles(theme: GrafanaTheme2) {
alignItems: 'center',
verticalAlign: 'middle',
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}),
};
}
@@ -36,9 +36,13 @@ export function DashboardLinksControls({ links, dashboard }: Props) {
function getStyles(theme: GrafanaTheme2) {
return {
linksContainer: css({
display: 'inline-flex',
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(1),
marginRight: theme.spacing(1),
maxWidth: '100%',
minWidth: 0,
order: 1,
flex: '1 1 0%',
}),
};
}
@@ -271,7 +271,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
public onEnterEditMode = () => {
// Save this state
this._initialState = sceneUtils.cloneSceneObjectState(this.state, { isDirty: false });
this._initialState = sceneUtils.cloneSceneObjectState(this.state);
this._initialUrlState = locationService.getLocation();
// Switch to edit mode
@@ -19,6 +19,7 @@ import { AddVariableButton } from './VariableControlsAddButton';
export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
const { variables } = sceneGraph.getVariables(dashboard)!.useState();
const styles = useStyles2(getStyles);
return (
<>
@@ -27,7 +28,11 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
.map((variable) => (
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
))}
{config.featureToggles.dashboardNewLayouts ? <AddVariableButton dashboard={dashboard} /> : null}
{config.featureToggles.dashboardNewLayouts ? (
<div className={styles.addButton}>
<AddVariableButton dashboard={dashboard} />
</div>
) : null}
</>
);
}
@@ -206,4 +211,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
alignItems: 'center',
}),
addButton: css({
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle',
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}),
});
@@ -1,9 +1,7 @@
import { css } from '@emotion/css';
import { PointerEventHandler, useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, useStyles2 } from '@grafana/ui';
import { Button } from '@grafana/ui';
import { openAddVariablePane } from '../settings/variables/VariableAddEditableElement';
import { DashboardInteractions } from '../utils/interactions';
@@ -11,7 +9,6 @@ import { DashboardInteractions } from '../utils/interactions';
import { DashboardScene } from './DashboardScene';
export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) {
const styles = useStyles2(getStyles);
const { editview, editPanel, isEditing, viewPanel } = dashboard.useState();
const handlePointerDown: PointerEventHandler = useCallback(
@@ -33,22 +30,10 @@ export function AddVariableButton({ dashboard }: { dashboard: DashboardScene })
}
return (
<div className={styles.addButton}>
<div className="dashboard-canvas-add-button">
<Button icon="plus" variant="primary" fill="text" onPointerDown={handlePointerDown}>
<Trans i18nKey="dashboard-scene.variable-controls.add-variable">Add variable</Trans>
</Button>
</div>
<div className="dashboard-canvas-add-button">
<Button icon="plus" variant="primary" fill="text" onPointerDown={handlePointerDown}>
<Trans i18nKey="dashboard-scene.variable-controls.add-variable">Add variable</Trans>
</Button>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
addButton: css({
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle',
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}),
});
@@ -119,16 +119,6 @@ export function colorIdEnumToColorIdV2(colorId: FieldColorModeIdV1 | string): Fi
return 'continuous-greens';
case FieldColorModeIdV1.ContinuousPurples:
return 'continuous-purples';
case FieldColorModeIdV1.ContinuousViridis:
return 'continuous-viridis';
case FieldColorModeIdV1.ContinuousMagma:
return 'continuous-magma';
case FieldColorModeIdV1.ContinuousPlasma:
return 'continuous-plasma';
case FieldColorModeIdV1.ContinuousInferno:
return 'continuous-inferno';
case FieldColorModeIdV1.ContinuousCividis:
return 'continuous-cividis';
case FieldColorModeIdV1.Fixed:
return 'fixed';
case FieldColorModeIdV1.Shades:
@@ -1268,16 +1268,6 @@ function colorIdToEnumv1(colorId: FieldColorModeId): FieldColorModeIdV1 {
return FieldColorModeIdV1.ContinuousGreens;
case 'continuous-purples':
return FieldColorModeIdV1.ContinuousPurples;
case 'continuous-viridis':
return FieldColorModeIdV1.ContinuousViridis;
case 'continuous-magma':
return FieldColorModeIdV1.ContinuousMagma;
case 'continuous-plasma':
return FieldColorModeIdV1.ContinuousPlasma;
case 'continuous-inferno':
return FieldColorModeIdV1.ContinuousInferno;
case 'continuous-cividis':
return FieldColorModeIdV1.ContinuousCividis;
case 'fixed':
return FieldColorModeIdV1.Fixed;
case 'shades':
@@ -182,6 +182,7 @@ function getStyles(theme: GrafanaTheme2) {
alignItems: 'center',
verticalAlign: 'middle',
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}),
};
}
@@ -789,6 +789,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
logOptionsStorageKey={SETTING_KEY_ROOT}
timeZone={timeZone}
displayedFields={displayedFields}
onPermalinkClick={onPermalinkClick}
onClickShowField={showField}
onClickHideField={hideField}
/>
@@ -19,7 +19,7 @@ import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
import {
getFieldSelectorWidth,
getSidebarWidth,
LogsTableFieldSelector,
MIN_WIDTH,
} from 'app/features/logs/components/fieldSelector/FieldSelector';
@@ -279,7 +279,7 @@ export function LogsTableWrap(props: Props) {
// The panel state is updated when the user interacts with the multi-select sidebar
}, [currentDataFrame, getColumnsFromProps]);
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(SETTING_KEY_ROOT));
const tableWidth = props.width - sidebarWidth;
const styles = useStyles2(getStyles, height, sidebarWidth);
@@ -35,7 +35,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
const { displayedFields, onClickShowField, onClickHideField, setDisplayedFields, logOptionsStorageKey } =
useLogListContext();
const [sidebarHeight, setSidebarHeight] = useState(220);
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(logOptionsStorageKey));
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(logOptionsStorageKey));
const dragStyles = useStyles2(getDragStyles);
useLayoutEffect(() => {
@@ -74,7 +74,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
}, [setSidebarWidthWrapper]);
const expand = useCallback(() => {
const width = getFieldSelectorWidth(logOptionsStorageKey);
const width = getSidebarWidth(logOptionsStorageKey);
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
reportInteraction('logs_field_selector_expand_clicked', {
mode: 'logs',
@@ -205,7 +205,7 @@ export const LogsTableFieldSelector = ({
}, [setSidebarWidthWrapper]);
const expand = useCallback(() => {
const width = getFieldSelectorWidth(SETTING_KEY_ROOT);
const width = getSidebarWidth(SETTING_KEY_ROOT);
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
reportInteraction('logs_field_selector_expand_clicked', {
mode: 'table',
@@ -436,7 +436,7 @@ function getSuggestedFields(logs: LogListModel[], displayedFields: string[], def
return suggestedFields;
}
export function getFieldSelectorWidth(logOptionsStorageKey?: string): number {
export function getSidebarWidth(logOptionsStorageKey?: string): number {
const width =
(logOptionsStorageKey
? parseInt(store.get(`${logOptionsStorageKey}.fieldSelector.width`) ?? DEFAULT_WIDTH, 10)
@@ -445,7 +445,7 @@ export function getFieldSelectorWidth(logOptionsStorageKey?: string): number {
return width < MIN_WIDTH ? MIN_WIDTH : width;
}
export function getFieldSelectorState(logOptionsStorageKey?: string): boolean | undefined {
export function getSidebarState(logOptionsStorageKey?: string): boolean | undefined {
if (!logOptionsStorageKey) {
return undefined;
}
@@ -3,7 +3,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState
import { LogRowModel, store } from '@grafana/data';
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
import { getSidebarWidth } from '../fieldSelector/FieldSelector';
import { LogLineDetailsMode } from './LogLineDetails';
import { LogListModel } from './processing';
@@ -56,7 +56,6 @@ export interface Props {
logs: LogRowModel[];
logOptionsStorageKey?: string;
showControls: boolean;
showFieldSelector?: boolean;
}
export const LogDetailsContextProvider = ({
@@ -69,13 +68,12 @@ export const LogDetailsContextProvider = ({
: getDefaultDetailsMode(containerElement),
logs,
showControls,
showFieldSelector,
}: Props) => {
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
const [currentLog, setCurrentLog] = useState<LogListModel | undefined>(undefined);
const [detailsWidth, setDetailsWidthState] = useState(
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls, showFieldSelector)
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls)
);
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(
detailsModeProp ?? getDefaultDetailsMode(containerElement)
@@ -103,10 +101,8 @@ export const LogDetailsContextProvider = ({
// Sync log details inline and sidebar width
useEffect(() => {
setDetailsWidthState(
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls, showFieldSelector)
);
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showFieldSelector]);
setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls));
}, [containerElement, detailsMode, logOptionsStorageKey, showControls]);
// Sync log details width
useEffect(() => {
@@ -115,20 +111,13 @@ export const LogDetailsContextProvider = ({
}
const handleResize = debounce(() => {
setDetailsWidthState((detailsWidth) =>
getDetailsWidth(
containerElement,
logOptionsStorageKey,
detailsWidth,
detailsMode,
showControls,
showFieldSelector
)
getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls)
);
}, 50);
const observer = new ResizeObserver(() => handleResize());
observer.observe(containerElement);
return () => observer.disconnect();
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails, showFieldSelector]);
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails]);
const closeDetails = useCallback(() => {
showDetails.forEach((log) => removeDetailsScrollPosition(log));
@@ -169,10 +158,7 @@ export const LogDetailsContextProvider = ({
return;
}
const maxWidth =
containerElement.clientWidth -
(showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) -
LOG_LIST_MIN_WIDTH;
const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH;
if (width > maxWidth) {
return;
}
@@ -180,7 +166,7 @@ export const LogDetailsContextProvider = ({
store.set(`${logOptionsStorageKey}.detailsWidth`, width);
setDetailsWidthState(width);
},
[containerElement, logOptionsStorageKey, showFieldSelector]
[containerElement, logOptionsStorageKey]
);
return (
@@ -210,14 +196,12 @@ export function getDetailsWidth(
logOptionsStorageKey?: string,
currentWidth?: number,
detailsMode: LogLineDetailsMode = 'sidebar',
showControls?: boolean,
showFieldSelector?: boolean
showControls?: boolean
) {
if (!containerElement) {
return 0;
}
const availableWidth =
containerElement.clientWidth - (showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0);
const availableWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey);
if (detailsMode === 'inline') {
return availableWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0);
}
@@ -20,7 +20,6 @@ import { setPluginLinksHook } from '@grafana/runtime';
import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
import { createLogLine } from '../mocks/logRow';
import { emptyContextData, LogDetailsContext, LogDetailsContextData } from './LogDetailsContext';
@@ -28,10 +27,6 @@ import { LogLineDetails, Props } from './LogLineDetails';
import { LogListContext, LogListContextData } from './LogListContext';
import { defaultValue } from './__mocks__/LogListContext';
jest.mock('../fieldSelector/FieldSelector');
jest.mocked(getFieldSelectorWidth).mockReturnValue(220);
jest.mock('@grafana/assistant', () => {
return {
...jest.requireActual('@grafana/assistant'),
@@ -84,7 +79,6 @@ const setup = (
},
timeZone: 'browser',
showControls: true,
showFieldSelector: true,
...(propOverrides || {}),
};
@@ -781,24 +775,4 @@ describe('LogLineDetails', () => {
expect(screen.getByText('value')).toBeInTheDocument();
expect(screen.getByText('Open service overview for label')).toBeInTheDocument();
});
describe('Width regressions', () => {
test('should consider Fields Selector width when enabled', () => {
jest.mocked(getFieldSelectorWidth).mockClear();
setup({ showFieldSelector: true }, { labels: { key1: 'label1', key2: 'label2' } });
expect(screen.getByText('Log line')).toBeInTheDocument();
expect(screen.getByText('Fields')).toBeInTheDocument();
expect(getFieldSelectorWidth).toHaveBeenCalled();
});
test('should not consider Fields Selector width when disabled', () => {
jest.mocked(getFieldSelectorWidth).mockClear();
setup({ showFieldSelector: false }, { labels: { key1: 'label1', key2: 'label2' } });
expect(screen.getByText('Log line')).toBeInTheDocument();
expect(screen.getByText('Fields')).toBeInTheDocument();
expect(getFieldSelectorWidth).not.toHaveBeenCalled();
});
});
});
@@ -7,7 +7,7 @@ import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui';
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
import { getSidebarWidth } from '../fieldSelector/FieldSelector';
import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogDetailsContext } from './LogDetailsContext';
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
@@ -22,13 +22,12 @@ export interface Props {
timeRange: TimeRange;
timeZone: string;
showControls: boolean;
showFieldSelector: boolean | undefined;
}
export type LogLineDetailsMode = 'inline' | 'sidebar';
export const LogLineDetails = memo(
({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls, showFieldSelector }: Props) => {
({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => {
const { noInteractions, logOptionsStorageKey } = useLogListContext();
const { detailsWidth, setDetailsWidth } = useLogDetailsContext();
const styles = useStyles2(getStyles, 'sidebar', showControls);
@@ -49,10 +48,7 @@ export const LogLineDetails = memo(
}
}, [noInteractions]);
const maxWidth =
containerElement.clientWidth -
(showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) -
LOG_LIST_MIN_WIDTH;
const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH;
return (
<Resizable
@@ -219,7 +219,6 @@ export const LogList = ({
logs={logs}
logOptionsStorageKey={logOptionsStorageKey}
showControls={showControls}
showFieldSelector={showFieldSelector}
>
<LogListSearchContextProvider>
<LogListComponent
@@ -459,7 +458,6 @@ const LogListComponent = ({
timeRange={timeRange}
timeZone={timeZone}
showControls={showControls}
showFieldSelector={showFieldSelector}
/>
)}
<div className={styles.logListWrapper} ref={wrapperRef}>
@@ -27,7 +27,7 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import { getFieldSelectorState } from '../fieldSelector/FieldSelector';
import { getSidebarState } from '../fieldSelector/FieldSelector';
import { getDisplayedFieldsForLogs } from '../otel/formats';
import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext';
@@ -245,7 +245,7 @@ export const LogListContextProvider = ({
dedupStrategy,
fontSize,
forceEscape: logListState.forceEscape,
fieldSelectorOpen: getFieldSelectorState(logOptionsStorageKey),
fieldSelectorOpen: getSidebarState(logOptionsStorageKey),
showTime,
showUniqueLabels,
syntaxHighlighting,
@@ -15,10 +15,13 @@ export default function GettingStartedPage({ items }: Props) {
return (
<Page
navId="provisioning"
subTitle={t(
'provisioning.getting-started-page.subtitle-provisioning-feature',
'View and manage your provisioning connections'
)}
pageNav={{
text: t('provisioning.getting-started-page.header', 'Provisioning'),
subTitle: t(
'provisioning.getting-started-page.subtitle-provisioning-feature',
'View and manage your provisioning connections'
),
}}
>
<Page.Contents>
<Stack direction="column" gap={3}>
@@ -2,6 +2,7 @@
// TODO: remove this when new Browse Dashboards UI is no longer feature flagged
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
export function getSearchPlaceholder(includePanels = false) {
return includePanels
@@ -10,7 +11,9 @@ export function getSearchPlaceholder(includePanels = false) {
}
export function getNewDashboardPhrase() {
return t('search.dashboard-actions.new-dashboard', 'New dashboard');
return config.featureToggles.dashboardTemplates
? t('search.dashboard-actions.empty-dashboard', 'Empty dashboard')
: t('search.dashboard-actions.new-dashboard', 'New dashboard');
}
export function getNewTemplateDashboardPhrase() {
@@ -566,6 +566,7 @@ export const LogsPanel = ({
logLineMenuCustomItems={isLogLineMenuCustomItems(logLineMenuCustomItems) ? logLineMenuCustomItems : undefined}
timeZone={timeZone}
displayedFields={displayedFields}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
onClickShowField={showField}
onClickHideField={hideField}
/>
@@ -7,7 +7,6 @@ import {
getFieldDisplayValues,
PanelProps,
} from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal';
import { config } from 'app/core/config';
@@ -15,7 +14,6 @@ import { config } from 'app/core/config';
import { Options } from './panelcfg.gen';
export function RadialBarPanel({
id,
height,
width,
data,
@@ -90,10 +88,6 @@ export function RadialBarPanel({
const minVizHeight = 60;
const minVizWidth = 60;
if (getValues()[0]?.display?.text === 'No data') {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} needsNumberField />;
}
return (
<Stack direction="row" justifyContent="center" alignItems="center" height={'100%'}>
<VizRepeater

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