Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f37986e97b | |||
| 29ad717011 |
Vendored
+12
-141
@@ -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",
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
+4
-18
@@ -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
|
||||
}
|
||||
|
||||
+10
-30
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-147
@@ -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 Grafana’s 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 Grafana’s 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/)
|
||||
-147
@@ -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/`
|
||||
-217
@@ -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
|
||||
|
||||
## Primary–replica 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 active–active.
|
||||
- **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.
|
||||
-93
@@ -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 region’s 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.
|
||||
-169
@@ -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 other’s 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.
|
||||
-86
@@ -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/
|
||||
|
||||
+1
-7
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-7
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user