Compare commits

..

2 Commits

Author SHA1 Message Date
Jayclifford345 f37986e97b prettier 2025-12-12 13:10:11 +00:00
Jayclifford345 29ad717011 make sue component is registered to show side bar 2025-12-12 13:01:49 +00:00
86 changed files with 358 additions and 2388 deletions
+12 -12
View File
@@ -77,11 +77,11 @@
/.air.toml @macabu
# Git Sync / App Platform Provisioning
/apps/provisioning/ @grafana/grafana-app-platform-squad
/pkg/operators @grafana/grafana-app-platform-squad
/public/app/features/provisioning @grafana/grafana-search-navigate-organise
/pkg/registry/apis/provisioning @grafana/grafana-app-platform-squad
/pkg/tests/apis/provisioning @grafana/grafana-app-platform-squad
/apps/provisioning/ @grafana/grafana-git-ui-sync-team
/pkg/operators @grafana/grafana-git-ui-sync-team
/public/app/features/provisioning @grafana/grafana-git-ui-sync-team
/pkg/registry/apis/provisioning @grafana/grafana-git-ui-sync-team
/pkg/tests/apis/provisioning @grafana/grafana-git-ui-sync-team
# Git Sync frontend owned by frontend team as a whole.
/apps/alerting/ @grafana/alerting-backend
@@ -753,7 +753,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-api-clients/src/clients/rtkq/iam/ @grafana/access-squad @grafana/identity-squad
/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/ @grafana/observability-logs
/packages/grafana-api-clients/src/clients/rtkq/preferences/ @grafana/plugins-platform-frontend
/packages/grafana-api-clients/src/clients/rtkq/provisioning/ @grafana/grafana-search-navigate-organise
/packages/grafana-api-clients/src/clients/rtkq/provisioning/ @grafana/grafana-git-ui-sync-team
/packages/grafana-api-clients/src/clients/rtkq/shorturl/ @grafana/sharing-squad
# root files, mostly frontend
@@ -1084,7 +1084,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
eslint-suppressions.json @grafanabot
# Design system
/public/img/icons/unicons/ @grafana/product-design-engineering
/public/img/icons/unicons/ @grafana/design-system
# Core datasources
/public/app/plugins/datasource/dashboard/ @grafana/dashboards-squad
@@ -1260,11 +1260,11 @@ embed.go @grafana/grafana-as-code
/.github/workflows/stale.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/storybook-a11y.yml @grafana/grafana-frontend-platform
/.github/workflows/update-make-docs.yml @grafana/docs-tooling
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/grafana-app-platform-squad
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-monitoring
/.github/workflows/scripts/create-security-branch/create-security-branch.sh @grafana/grafana-developer-enablement-squad
/.github/workflows/publish-kinds-next.yml @grafana/grafana-app-platform-squad
/.github/workflows/publish-kinds-release.yml @grafana/grafana-app-platform-squad
/.github/workflows/verify-kinds.yml @grafana/grafana-app-platform-squad
/.github/workflows/publish-kinds-next.yml @grafana/platform-monitoring
/.github/workflows/publish-kinds-release.yml @grafana/platform-monitoring
/.github/workflows/verify-kinds.yml @grafana/platform-monitoring
/.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad
/.github/workflows/run-schema-v2-e2e.yml @grafana/dashboards-squad
/.github/workflows/run-dashboard-search-e2e.yml @grafana/grafana-search-and-storage
@@ -1325,7 +1325,7 @@ embed.go @grafana/grafana-as-code
/conf/provisioning/dashboards/ @grafana/dashboards-squad
/conf/provisioning/datasources/ @grafana/plugins-platform-backend
/conf/provisioning/plugins/ @grafana/plugins-platform-backend
/conf/provisioning/sample/ @grafana/grafana-app-platform-squad
/conf/provisioning/sample/ @grafana/grafana-git-ui-sync-team
# Security
/relyance.yaml @grafana/security-team
@@ -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,
@@ -198,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"`
@@ -226,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.
@@ -401,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
}
@@ -473,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))
@@ -889,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)",
@@ -905,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{
@@ -918,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: "",
},
},
},
},
},
},
},
},
@@ -1050,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,10 +3,8 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Warnings
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Summary
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Warnings
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Resources
@@ -7,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.
@@ -75,9 +75,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -152,9 +152,9 @@
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -229,9 +229,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -306,9 +306,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -383,9 +383,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -460,9 +460,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -537,9 +537,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -627,9 +627,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -704,9 +704,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -781,9 +781,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -858,9 +858,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -952,9 +952,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1029,9 +1029,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1106,9 +1106,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1183,9 +1183,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1260,9 +1260,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1354,9 +1354,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1435,9 +1435,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1516,9 +1516,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"spotlight": false,
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1565,6 +1565,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1605,9 +1606,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1630,6 +1631,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1647,6 +1649,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1687,9 +1690,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1712,6 +1715,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 98,
"min": 5,
"noise": 22,
@@ -1742,6 +1746,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1783,9 +1788,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1808,6 +1813,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 8,
"min": 1,
"noise": 2,
@@ -1825,6 +1831,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1866,9 +1873,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1891,6 +1898,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 12,
"min": 1,
"noise": 2,
@@ -1908,6 +1916,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -1948,9 +1957,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1973,6 +1982,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -1990,6 +2000,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
@@ -2030,9 +2041,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"spotlight": true,
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -2055,6 +2066,7 @@
"datasource": {
"type": "grafana-testdata-datasource"
},
"hide": false,
"max": 100,
"min": 10,
"noise": 22,
@@ -2067,147 +2079,6 @@
],
"title": "Backend",
"type": "radialbar"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 66
},
"id": 35,
"panels": [],
"title": "Empty data",
"type": "row"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 67
},
"id": 36,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 0
}
],
"title": "Numeric, no series",
"type": "gauge"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 67
},
"id": 37,
"options": {
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "logs"
}
],
"title": "Non-numeric",
"type": "gauge"
}
],
"preload": false,
@@ -2224,5 +2095,5 @@
"timezone": "browser",
"title": "Panel tests - Gauge (new)",
"uid": "panel-tests-gauge-new",
"version": 9
"version": 6
}
@@ -59,9 +59,9 @@ For more details on contact points, including how to test them and enable notifi
## Alertmanager settings
| Option | Description |
| ------ | ----------------------------------------------------------------------------------------------------------------- |
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Option | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
#### Optional settings
@@ -49,14 +49,14 @@ For more details on contact points, including how to test them and enable notifi
### Required Settings
| Key | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
| Key | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
### Optional Settings
@@ -54,10 +54,10 @@ For more details on contact points, including how to test them and enable notifi
### Required Settings
| Option | Description |
| ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Topic | The topic to which the message will be sent. |
| Option | Description |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
| Topic | The topic to which the message will be sent. |
### Optional Settings
@@ -51,8 +51,8 @@ You can customize the `title` and `body` of the Slack message using [notificatio
If you are using a Slack API Token, complete the following steps.
1. Follow step 1 of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#creating) to create the app.
1. Continue onto the second step of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#scopes) and add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope as described to give your app the ability to post in all public channels without joining.
1. Follow steps 1 and 2 of the [Slack API Quickstart](https://api.slack.com/start/quickstart).
1. Add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope to give your app the ability to post in all public channels without joining.
1. In OAuth Tokens for Your Workspace, copy the Bot User OAuth Token.
1. Open your Slack workplace.
1. Right click the channel you want to receive notifications in.
@@ -62,9 +62,9 @@ For more details on contact points, including how to test them and enable notifi
## Webhook settings
| Option | Description |
| ------ | ------------------------------------------------------------------------------------------------------------ |
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
| Option | Description |
| ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
#### Optional settings
@@ -81,7 +81,7 @@ Replace the placeholders with your values:
In your `grafana` directory, create a sub-folder called `dashboards`.
This guide shows you how to create three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud Stack
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
@@ -1,147 +0,0 @@
---
title: Git Sync deployment scenarios
menuTitle: Deployment scenarios
description: Learn about common Git Sync deployment patterns and configurations for different organizational needs
weight: 450
keywords:
- git sync
- deployment patterns
- scenarios
- multi-environment
- teams
---
# Git Sync deployment scenarios
This guide shows practical deployment scenarios for Grafanas Git Sync. Learn how to configure bidirectional synchronization between Grafana and Git repositories for teams, environments, and regions.
{{< admonition type="caution" >}}
Git Sync is an experimental feature. It reflects Grafanas approach to Observability as Code and might include limitations or breaking changes. For current status and known limitations, refer to the [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/).
{{< /admonition >}}
## Understand the relationship between key Git Sync components
Before you explore the scenarios, understand how the key Git Sync components relate:
- [Grafana instance](#grafana-instance)
- [Git repository structure](#git-repository-structure)
- [Git Sync repository resource](#git-sync-repository-resource)
### Grafana instance
A Grafana instance is a running Grafana server. Multiple instances can:
- Connect to the same Git repository using different Repository configurations.
- Sync from different branches of the same repository.
- Sync from different paths within the same repository.
- Sync from different repositories.
### Git repository structure
You can organize your Git repository in several ways:
- Single branch, multiple paths: Use different directories for different purposes (for example, `dev/`, `prod/`, `team-a/`).
- Multiple branches: Use different branches for different environments or teams (for example, `main`, `develop`, `team-a`).
- Multiple repositories: Use separate repositories for different teams or environments.
### Git Sync repository resource
A repository resource is a Grafana configuration object that defines:
- Which Git repository to sync with.
- Which branch to use.
- Which directory path to synchronize.
- Sync behavior and workflows.
Each repository resource creates bidirectional synchronization between a Grafana instance and a specific location in Git.
## How does repository sync behave?
With Git Sync you configure a repository resource to sync with your Grafana instance:
1. Grafana monitors the specified Git location (repository, branch, and path).
2. Grafana creates a folder in Dashboards (typically named after the repository).
3. Grafana creates dashboards from dashboard JSON files in Git within this folder.
4. Grafana commits dashboard changes made in the UI back to Git.
5. Grafana pulls dashboard changes made in Git and updates dashboards in the UI.
6. Synchronization occurs at regular intervals (configurable), or instantly if you use webhooks.
You can find the provisioned dashboards organized in folders under **Dashboards**.
## Example: Relationship between repository, branch, and path
Here's a concrete example showing how the three parameters work together:
**Configuration:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-platform/grafana/`
**In Git (on branch `main`):**
```
your-org/grafana-manifests/
├── .git/
├── README.md
├── team-platform/
│ └── grafana/
│ ├── cpu-metrics.json ← Synced
│ ├── memory-usage.json ← Synced
│ └── disk-io.json ← Synced
├── team-data/
│ └── grafana/
│ └── pipeline-stats.json ← Not synced (different path)
└── other-files.txt ← Not synced (outside path)
```
**In Grafana Dashboards view:**
```
Dashboards
└── 📁 grafana-manifests/
├── CPU Metrics Dashboard
├── Memory Usage Dashboard
└── Disk I/O Dashboard
```
**Key points:**
- Grafana only synchronizes files within the specified path (`team-platform/grafana/`).
- Grafana ignores files in other paths or at the repository root.
- The folder name in Grafana comes from the repository name.
- Dashboard titles come from the JSON file content, not the filename.
## Repository configuration flexibility
Git Sync repositories support different combinations of repository URL, branch, and path:
- Different Git repositories: Each environment or team can use its own repository.
- Instance A: `repository: your-org/grafana-prod`.
- Instance B: `repository: your-org/grafana-dev`.
- Different branches: Use separate branches within the same repository.
- Instance A: `repository: your-org/grafana-manifests, branch: main`.
- Instance B: `repository: your-org/grafana-manifests, branch: develop`.
- Different paths: Use different directory paths within the same repository.
- Instance A: `repository: your-org/grafana-manifests, branch: main, path: production/`.
- Instance B: `repository: your-org/grafana-manifests, branch: main, path: development/`.
- Any combination: Mix and match based on your workflow requirements.
## Scenarios
Use these deployment scenarios to plan your Git Sync setup:
- [Single instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance/)
- [Git Sync for development and production environments](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod/)
- [Git Sync with regional replication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region/)
- [High availability](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability/)
- [Git Sync in a shared Grafana instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team/)
## Learn more
Refer to the following documents to learn more:
- [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/)
- [Git Sync setup guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-setup/)
- [Dashboard provisioning](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/)
- [Observability as Code](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/)
@@ -1,147 +0,0 @@
---
title: Git Sync for development and production environments
menuTitle: Across environments
description: Use separate Grafana instances for development and production with Git-controlled promotion
weight: 20
---
# Git Sync for development and production environments
Use separate Grafana instances for development and production. Each syncs with different Git locations to test dashboards before production.
## Use it for
- **Staged deployments**: You need to test dashboard changes before production deployment.
- **Change control**: You require approvals before dashboards reach production.
- **Quality assurance**: You verify dashboard functionality in a non-production environment.
- **Risk mitigation**: You minimize the risk of breaking production dashboards.
## Architecture
```
┌────────────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ ├── dev/ │
│ │ ├── dashboard-new.json ← Development dashboards │
│ │ └── dashboard-test.json │
│ │ │
│ └── prod/ │
│ ├── dashboard-stable.json ← Production dashboards │
│ └── dashboard-approved.json │
└────────────────────────────────────────────────────────────┘
↕ ↕
Git Sync (dev/) Git Sync (prod/)
↕ ↕
┌─────────────────────┐ ┌─────────────────────┐
│ Dev Grafana │ │ Prod Grafana │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: dev/ │ │ - path: prod/ │
│ │ │ │
│ Creates folder: │ │ Creates folder: │
│ "grafana-manifests"│ │ "grafana-manifests"│
└─────────────────────┘ └─────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
├── dev/
│ ├── dashboard-new.json
│ └── dashboard-test.json
└── prod/
├── dashboard-stable.json
└── dashboard-approved.json
```
**In Grafana Dashboards view:**
**Dev instance:**
```
Dashboards
└── 📁 grafana-manifests/
├── New Dashboard
└── Test Dashboard
```
**Prod instance:**
```
Dashboards
└── 📁 grafana-manifests/
├── Stable Dashboard
└── Approved Dashboard
```
- Both instances create a folder named "grafana-manifests" (from repository name)
- Each instance only shows dashboards from its configured path (`dev/` or `prod/`)
- Dashboards appear with their titles from the JSON files
## Configuration parameters
Development:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `dev/`
Production:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `prod/`
## How it works
1. Developers create and modify dashboards in development.
2. Git Sync commits changes to `dev/`.
3. You review changes in Git.
4. You promote approved dashboards from `dev/` to `prod/`.
5. Production syncs from `prod/`.
6. Production dashboards update.
## Alternative: Use branches
Instead of using different paths, you can configure instances to use different branches:
**Development instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `develop`
- **Path**: `grafana/`
**Production instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `grafana/`
With this approach:
- Development changes go to the `develop` branch
- Use Git merge or pull request workflows to promote changes from `develop` to `main`
- Production automatically syncs from the `main` branch
## Alternative: Use separate repositories for stricter isolation
For stricter isolation, use completely separate repositories:
**Development instance:**
- **Repository**: `your-org/grafana-manifests-dev`
- **Branch**: `main`
- **Path**: `grafana/`
**Production instance:**
- **Repository**: `your-org/grafana-manifests-prod`
- **Branch**: `main`
- **Path**: `grafana/`
@@ -1,217 +0,0 @@
---
title: Git Sync for high availability environments
menuTitle: High availability
description: Run multiple Grafana instances serving traffic simultaneously, synchronized via Git Sync
weight: 50
---
# Git Sync for high availability environments
## Primaryreplica scenario
Use a primary Grafana instance and one or more replicas synchronized with the same Git location to enable failover.
### Use it for
- **Automatic failover**: You need service continuity when the primary instance fails.
- **High availability**: Your organization requires guaranteed dashboard availability.
- **Simple HA setup**: You want high availability without the complexity of activeactive.
- **Maintenance windows**: You perform updates while another instance serves traffic.
- **Business continuity**: Dashboard access can't tolerate downtime.
### Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-metrics.json │
│ ├── dashboard-alerts.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ Master Grafana │ │ Replica Grafana │
│ (Active) │ │ (Standby) │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
│ │
└───────────┬───────────────────┘
┌──────────────────────┐
│ Reverse Proxy │
│ (Failover) │
└──────────────────────┘
```
### Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-metrics.json
├── dashboard-alerts.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (both instances):**
```
Dashboards
└── 📁 grafana-manifests/
├── Metrics Dashboard
├── Alerts Dashboard
└── Logs Dashboard
```
- Master and replica instances show identical folder structure.
- Both sync from the same `shared/` path.
- Reverse proxy routes traffic to master (active) instance.
- If master fails, proxy automatically fails over to replica (standby).
- Users see the same dashboards regardless of which instance is serving traffic.
### Configuration parameters
Both master and replica instances use identical parameters:
**Master instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
**Replica instance:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
### How it works
1. Both instances stay synchronized through Git.
2. Reverse proxy routes traffic to primary.
3. Users edit on primary. Git Sync commits changes.
4. Both instances pull latest changes to keep replica in sync.
5. On primary failure, proxy fails over to replica.
### Failover considerations
- Health checks and monitoring.
- Continuous syncing to minimize data loss.
- Plan failback (automatic or manual).
## Load balancer scenario
Run multiple active Grafana instances behind a load balancer. All instances sync from the same Git location.
### Use it for
- **High traffic**: Your deployment needs to handle significant user load.
- **Load distribution**: You want to distribute user requests across instances.
- **Maximum availability**: You need service continuity during maintenance or failures.
- **Scalability**: You want to add instances as load increases.
- **Performance**: Users need fast response times under heavy load.
### Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-metrics.json │
│ ├── dashboard-alerts.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ Grafana Instance 1│ │ Grafana Instance 2│
│ (Active) │ │ (Active) │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
│ │
└───────────┬───────────────────┘
┌──────────────────────┐
│ Load Balancer │
│ (Round Robin) │
└──────────────────────┘
```
### Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-metrics.json
├── dashboard-alerts.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (all instances):**
```
Dashboards
└── 📁 grafana-manifests/
├── Metrics Dashboard
├── Alerts Dashboard
└── Logs Dashboard
```
- All instances show identical folder structure.
- All instances sync from the same `shared/` path.
- Load balancer distributes requests across all active instances.
- Any instance can serve read requests.
- Any instance can accept dashboard modifications.
- Changes propagate to all instances through Git.
### Configuration parameters
All instances use identical parameters:
**Instance 1:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
**Instance 2:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `shared/`
### How it works
1. All instances stay synchronized through Git.
2. Load balancer distributes incoming traffic across all active instances.
3. Users can view dashboards from any instance.
4. When a user modifies a dashboard on any instance, Git Sync commits the change.
5. All other instances pull the updated dashboard during their next sync cycle, or instantly if webhooks are configured.
6. If one instance fails, load balancer stops routing traffic to it and remaining instances continue serving.
### Important considerations
- **Eventually consistent**: Due to sync intervals, instances may briefly have different dashboard versions.
- **Concurrent edits**: Multiple users editing the same dashboard on different instances can cause conflicts.
- **Database sharing**: Instances should share the same backend database for user sessions, preferences, and annotations.
- **Stateless design**: Design for stateless operation where possible to maximize load balancing effectiveness.
@@ -1,93 +0,0 @@
---
title: Git Sync with regional replication
menuTitle: Regional replication
description: Synchronize multiple regional Grafana instances from a shared Git location
weight: 30
---
# Git Sync with regional replication
Deploy multiple Grafana instances across regions. Synchronize them with the same Git location to ensure consistent dashboards everywhere.
## Use it for
- **Geographic distribution**: You deploy Grafana close to users in different regions.
- **Latency reduction**: Users need fast dashboard access from their location.
- **Data sovereignty**: You keep dashboard data in specific regions.
- **High availability**: You need dashboard availability across regions.
- **Consistent experience**: All users see the same dashboards regardless of region.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── shared/ │
│ ├── dashboard-global.json │
│ ├── dashboard-metrics.json │
│ └── dashboard-logs.json │
└─────────────────────────────────────────────────────┘
↕ ↕
Git Sync (shared/) Git Sync (shared/)
↕ ↕
┌────────────────────┐ ┌────────────────────┐
│ US Region │ │ EU Region │
│ Grafana │ │ Grafana │
│ │ │ │
│ Repository: │ │ Repository: │
│ - path: shared/ │ │ - path: shared/ │
└────────────────────┘ └────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
└── shared/
├── dashboard-global.json
├── dashboard-metrics.json
└── dashboard-logs.json
```
**In Grafana Dashboards view (all regions):**
```
Dashboards
└── 📁 grafana-manifests/
├── Global Dashboard
├── Metrics Dashboard
└── Logs Dashboard
```
- All regional instances (US, EU, etc.) show identical folder structure
- Same folder name "grafana-manifests" in every region
- Same dashboards synced from the `shared/` path appear everywhere
- Users in any region see the exact same dashboards with the same titles
## Configuration parameters
All regions:
- Repository: `your-org/grafana-manifests`
- Branch: `main`
- Path: `shared/`
## How it works
1. All regional instances pull dashboards from `shared/`.
2. Any regions change commits to Git.
3. Other regions pull updates during the next sync (or via webhooks).
4. Changes propagate across regions per sync interval.
## Considerations
- **Write conflicts**: If users in different regions modify the same dashboard simultaneously, Git uses last-write-wins.
- **Primary region**: Consider designating one region as the primary location for making dashboard changes.
- **Propagation time**: Changes propagate to all regions within the configured sync interval, or instantly if webhooks are configured.
- **Network reliability**: Ensure all regions have reliable connectivity to the Git repository.
@@ -1,169 +0,0 @@
---
title: Multiple team Git Sync
menuTitle: Shared instance
description: Use multiple Git repositories with one Grafana instance, one repository per team
weight: 60
---
# Git Sync in a Grafana instance shared by multiple teams
Use a single Grafana instance with multiple Repository resources, one per team. Each team manages its own dashboards while sharing Grafana.
## Use it for
- **Team autonomy**: Different teams manage their own dashboards independently.
- **Organizational structure**: Dashboard organization aligns with team structure.
- **Resource efficiency**: Multiple teams share Grafana infrastructure.
- **Cost optimization**: You reduce infrastructure costs while maintaining team separation.
- **Collaboration**: Teams can view each others dashboards while managing their own.
## Architecture
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ Platform Team Repo │ │ Data Team Repo │
│ platform-dashboards │ │ data-dashboards │
│ │ │ │
│ platform-dashboards/ │ │ data-dashboards/ │
│ └── grafana/ │ │ └── grafana/ │
│ ├── k8s.json │ │ ├── pipeline.json │
│ └── infra.json │ │ └── analytics.json │
└─────────────────────────┘ └─────────────────────────┘
↕ ↕
Git Sync (grafana/) Git Sync (grafana/)
↕ ↕
┌──────────────────────────────────────┐
│ Grafana Instance │
│ │
│ Repository 1: │
│ - repo: platform-dashboards │
│ → Creates "platform-dashboards" │
│ │
│ Repository 2: │
│ - repo: data-dashboards │
│ → Creates "data-dashboards" │
└──────────────────────────────────────┘
```
## Repository structure
**In Git (separate repositories):**
**Platform team repository:**
```
your-org/platform-dashboards
└── grafana/
├── dashboard-k8s.json
└── dashboard-infra.json
```
**Data team repository:**
```
your-org/data-dashboards
└── grafana/
├── dashboard-pipeline.json
└── dashboard-analytics.json
```
**In Grafana Dashboards view:**
```
Dashboards
├── 📁 platform-dashboards/
│ ├── Kubernetes Dashboard
│ └── Infrastructure Dashboard
└── 📁 data-dashboards/
├── Pipeline Dashboard
└── Analytics Dashboard
```
- Two separate folders created (one per Repository resource).
- Folder names derived from repository names.
- Each team has complete control over their own repository.
- Teams can independently manage permissions, branches, and workflows in their repos.
- All teams can view each other's dashboards in Grafana but manage only their own.
## Configuration parameters
**Platform team repository:**
- **Repository**: `your-org/platform-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
**Data team repository:**
- **Repository**: `your-org/data-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
## How it works
1. Each team has their own Git repository for complete autonomy.
2. Each repository resource in Grafana creates a separate folder.
3. Platform team dashboards sync from `your-org/platform-dashboards` repository.
4. Data team dashboards sync from `your-org/data-dashboards` repository.
5. Teams can independently manage their repository settings, access controls, and workflows.
6. All teams can view each other's dashboards in Grafana but edit only their own.
## Scale to more teams
Adding additional teams is straightforward. For a third team, create a new repository and configure:
- **Repository**: `your-org/security-dashboards`
- **Branch**: `main`
- **Path**: `grafana/`
This creates a new "security-dashboards" folder in the same Grafana instance.
## Alternative: Shared repository with different paths
For teams that prefer sharing a single repository, use different paths to separate team dashboards:
**In Git:**
```
your-org/grafana-manifests
├── team-platform/
│ ├── dashboard-k8s.json
│ └── dashboard-infra.json
└── team-data/
├── dashboard-pipeline.json
└── dashboard-analytics.json
```
**Configuration:**
**Platform team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-platform/`
**Data team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `team-data/`
This approach provides simpler repository management but less isolation between teams.
## Alternative: Different branches per team
For teams wanting their own branch in a shared repository:
**Platform team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `team-platform`
- **Path**: `grafana/`
**Data team:**
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `team-data`
- **Path**: `grafana/`
This allows teams to use Git branch workflows for collaboration while sharing the same repository.
@@ -1,86 +0,0 @@
---
title: Single instance Git Sync
menuTitle: Single instance
description: Synchronize a single Grafana instance with a Git repository
weight: 10
---
# Single instance Git Sync
Use a single Grafana instance synchronized with a Git repository. This is the foundation for Git Sync and helps you understand bidirectional synchronization.
## Use it for
- **Getting started**: You want to learn how Git Sync works before implementing complex scenarios.
- **Personal projects**: Individual developers manage their own dashboards.
- **Small teams**: You have a simple setup without multiple environments or complex workflows.
- **Development environments**: You need quick prototyping and testing.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ GitHub Repository │
│ Repository: your-org/grafana-manifests │
│ Branch: main │
│ │
│ grafana-manifests/ │
│ └── grafana/ │
│ ├── dashboard-1.json │
│ ├── dashboard-2.json │
│ └── dashboard-3.json │
└─────────────────────────────────────────────────────┘
Git Sync (bidirectional)
┌─────────────────────────────┐
│ Grafana Instance │
│ │
│ Repository Resource: │
│ - url: grafana-manifests │
│ - branch: main │
│ - path: grafana/ │
│ │
│ Creates folder: │
│ "grafana-manifests" │
└─────────────────────────────┘
```
## Repository structure
**In Git:**
```
your-org/grafana-manifests
└── grafana/
├── dashboard-1.json
├── dashboard-2.json
└── dashboard-3.json
```
**In Grafana Dashboards view:**
```
Dashboards
└── 📁 grafana-manifests/
├── Dashboard 1
├── Dashboard 2
└── Dashboard 3
```
- A folder named "grafana-manifests" (from repository name) contains all synced dashboards.
- Each JSON file becomes a dashboard with its title displayed in the folder.
- Users browse dashboards organized under this folder structure.
## Configuration parameters
Configure your Grafana instance to synchronize with:
- **Repository**: `your-org/grafana-manifests`
- **Branch**: `main`
- **Path**: `grafana/`
## How it works
1. **From Grafana to Git**: When users create or modify dashboards in Grafana, Git Sync commits changes to the `grafana/` directory on the `main` branch.
2. **From Git to Grafana**: When dashboard JSON files are added or modified in the `grafana/` directory, Git Sync pulls these changes into Grafana.
@@ -367,6 +367,5 @@ To learn more about using Git Sync:
- [Work with provisioned dashboards](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/provisioned-dashboards/)
- [Manage provisioned repositories with Git Sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/use-git-sync/)
- [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios)
- [Export resources](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/export-resources/)
- [grafanactl documentation](https://grafana.github.io/grafanactl/)
@@ -127,13 +127,7 @@ An instance can be in one of the following Git Sync states:
## Common use cases
{{< admonition type="note" >}}
Refer to [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios) for sample scenarios, including architecture and configuration details.
{{< /admonition >}}
You can use Git Sync for the following use cases:
You can use Git Sync in the following scenarios.
### Version control and auditing
@@ -14,7 +14,7 @@ labels:
- cloud
title: Manage provisioned repositories with Git Sync
menuTitle: Manage repositories with Git Sync
weight: 400
weight: 120
canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/use-git-sync/
aliases:
- ../../../observability-as-code/provision-resources/use-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/use-git-sync/
@@ -1138,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;
@@ -1146,9 +1146,6 @@ export type JobResourceSummary = {
noop?: number;
total?: number;
update?: number;
/** The error count */
warning?: number;
warnings?: string[];
write?: number;
};
export type RepositoryUrLs = {
@@ -1179,7 +1176,6 @@ export type JobStatus = {
summary?: JobResourceSummary[];
/** URLs contains URLs for the reference branch or commit if applicable. */
url?: RepositoryUrLs;
warnings?: string[];
};
export type Job = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
@@ -106,11 +106,6 @@ export function RadialGauge(props: RadialGaugeProps) {
const gaugeId = useId();
const styles = useStyles2(getStyles);
let effectiveTextMode = textMode;
if (effectiveTextMode === 'auto') {
effectiveTextMode = vizCount === 1 ? 'value' : 'value_and_name';
}
const startAngle = shape === 'gauge' ? 250 : 0;
const endAngle = shape === 'gauge' ? 110 : 360;
@@ -193,7 +188,7 @@ export function RadialGauge(props: RadialGaugeProps) {
// These elements are only added for first value / bar
if (barIndex === 0) {
if (glowBar) {
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} barWidth={dimensions.barWidth} />);
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} radius={dimensions.radius} />);
}
if (glowCenter) {
@@ -203,14 +198,14 @@ export function RadialGauge(props: RadialGaugeProps) {
graphics.push(
<RadialText
key="radial-text"
textMode={effectiveTextMode}
vizCount={vizCount}
textMode={textMode}
displayValue={displayValue.display}
dimensions={dimensions}
theme={theme}
valueManualFontSize={props.valueManualFontSize}
nameManualFontSize={props.nameManualFontSize}
shape={shape}
sparkline={displayValue.sparkline}
/>
);
@@ -259,7 +254,6 @@ export function RadialGauge(props: RadialGaugeProps) {
theme={theme}
color={color}
shape={shape}
textMode={effectiveTextMode}
/>
);
}
@@ -1,9 +1,11 @@
import { css } from '@emotion/css';
import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data';
import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
interface RadialSparklineProps {
@@ -12,22 +14,23 @@ interface RadialSparklineProps {
theme: GrafanaTheme2;
color?: string;
shape?: RadialShape;
textMode: Exclude<RadialTextMode, 'auto'>;
}
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
const { radius, barWidth } = dimensions;
export function RadialSparkline({ sparkline, dimensions, theme, color, shape }: RadialSparklineProps) {
if (!sparkline) {
return null;
}
const showNameAndValue = textMode === 'value_and_name';
const height = radius / (showNameAndValue ? 4 : 3);
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
const topPos =
shape === 'gauge'
? `${dimensions.gaugeBottomY - height}px`
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
const { radius, barWidth } = dimensions;
const height = radius / 4;
const widthFactor = shape === 'gauge' ? 1.6 : 1.4;
const width = radius * widthFactor - barWidth;
const topPos = shape === 'gauge' ? `${dimensions.gaugeBottomY - height}px` : `calc(50% + ${radius / 2.8}px)`;
const styles = css({
position: 'absolute',
top: topPos,
});
const config: FieldConfig<GraphFieldConfig> = {
color: {
@@ -42,7 +45,7 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape, te
};
return (
<div style={{ position: 'absolute', top: topPos }}>
<div className={styles}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
</div>
);
@@ -1,12 +1,6 @@
import { css } from '@emotion/css';
import {
DisplayValue,
DisplayValueAlignmentFactors,
FieldSparkline,
formattedValueToString,
GrafanaTheme2,
} from '@grafana/data';
import { DisplayValue, DisplayValueAlignmentFactors, formattedValueToString, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
@@ -14,13 +8,21 @@ import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
// function toCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) {
// let radian = ((angleInDegrees - 90) * Math.PI) / 180.0;
// return {
// x: centerX + radius * Math.cos(radian),
// y: centerY + radius * Math.sin(radian),
// };
// }
interface RadialTextProps {
displayValue: DisplayValue;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
textMode: Exclude<RadialTextMode, 'auto'>;
textMode: RadialTextMode;
vizCount: number;
shape: RadialShape;
sparkline?: FieldSparkline;
alignmentFactors?: DisplayValueAlignmentFactors;
valueManualFontSize?: number;
nameManualFontSize?: number;
@@ -31,8 +33,8 @@ export function RadialText({
theme,
dimensions,
textMode,
vizCount,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
@@ -44,6 +46,10 @@ export function RadialText({
return null;
}
if (textMode === 'auto') {
textMode = vizCount === 1 ? 'value' : 'value_and_name';
}
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
@@ -53,7 +59,7 @@ export function RadialText({
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.82;
const valueWidthToRadiusFactor = 0.85;
const nameToHeightFactor = 0.45;
const largeRadiusScalingDecay = 0.86;
@@ -92,23 +98,18 @@ export function RadialText({
const valueHeight = valueFontSize * lineHeight;
const nameHeight = nameFontSize * lineHeight;
const valueY = showName ? centerY - nameHeight * 0.3 : centerY;
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
const valueY = showName ? centerY - nameHeight / 2 : centerY;
const valueNameSpacing = valueHeight / 3.5;
const nameY = showValue ? valueY + valueHeight / 2 + valueNameSpacing : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= 8;
}
// For gauge shape we shift text up a bit
const valueDy = shape === 'gauge' ? -valueFontSize * 0.3 : 0;
const nameDy = shape === 'gauge' ? -nameFontSize * 0.7 : 0;
return (
<g transform={`translate(0, ${yOffset})`}>
<g>
{showValue && (
<text
x={centerX}
@@ -118,6 +119,7 @@ export function RadialText({
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
dy={valueDy}
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
@@ -131,6 +133,7 @@ export function RadialText({
fontSize={nameFontSize}
x={centerX}
y={nameY}
dy={nameDy}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
@@ -4,12 +4,11 @@ import { GaugeDimensions } from './utils';
export interface GlowGradientProps {
id: string;
barWidth: number;
radius: number;
}
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
// 0.75 is the minimum glow size, and it scales with bar width
const glowSize = 0.75 + barWidth * 0.08;
export function GlowGradient({ id, radius }: GlowGradientProps) {
const glowSize = 0.02 * radius;
return (
<filter id={id} filterUnits="userSpaceOnUse">
@@ -83,7 +82,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
<>
<defs>
<radialGradient id={gradientId} r={'50%'} fr={'0%'}>
<stop offset="0%" stopColor={color} stopOpacity={0.15} />
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
</defs>
@@ -16,7 +16,7 @@ export interface SparklineProps extends Themeable2 {
sparkline: FieldSparkline;
}
const SparklineFn: React.FC<SparklineProps> = memo((props) => {
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
@@ -30,14 +30,4 @@ const SparklineFn: React.FC<SparklineProps> = memo((props) => {
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
SparklineFn.displayName = 'Sparkline';
// we converted to function component above, but some apps extend Sparkline, so we need
// to keep exporting a class component until those apps are all rolled out.
// see https://github.com/grafana/app-observability-plugin/pull/2079
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
export class Sparkline extends React.PureComponent<SparklineProps> {
render() {
return <SparklineFn {...this.props} />;
}
}
Sparkline.displayName = 'Sparkline';
-29
View File
@@ -1,29 +0,0 @@
package auditing
import (
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// NoopBackend is a no-op implementation of audit.Backend
type NoopBackend struct{}
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
func (NoopBackend) Shutdown() {}
func (NoopBackend) String() string { return "" }
// NoopPolicyRuleEvaluator is a no-op implementation of audit.PolicyRuleEvaluator
type NoopPolicyRuleEvaluator struct{}
func ProvideNoopPolicyRuleEvaluator() audit.PolicyRuleEvaluator { return &NoopPolicyRuleEvaluator{} }
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
}
+16 -24
View File
@@ -61,24 +61,20 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
}
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
}()
return s.datasources.GetDataSource(ctx, name)
}
// Create implements rest.Creater.
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
ds, ok := obj.(*v0alpha1.DataSource)
if !ok {
@@ -89,12 +85,10 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
// Update implements rest.Updater.
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
old, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
@@ -132,12 +126,10 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
// Delete implements rest.GracefulDeleter.
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
err := s.datasources.DeleteDataSource(ctx, name)
return nil, false, err
+15 -27
View File
@@ -3,7 +3,6 @@ package datasource
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
@@ -39,14 +38,14 @@ var (
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
datasourceResourceInfo utils.ResourceInfo
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
dataSourceCRUDMetric *prometheus.HistogramVec
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
}
func RegisterAPIService(
@@ -67,16 +66,6 @@ func RegisterAPIService(
var err error
var builder *DataSourceAPIBuilder
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
regErr := reg.Register(dataSourceCRUDMetric)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr
}
pluginJSONs, err := getDatasourcePlugins(pluginSources)
if err != nil {
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
@@ -102,7 +91,6 @@ func RegisterAPIService(
if err != nil {
return nil, err
}
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
apiRegistrar.RegisterAPI(builder)
}
@@ -173,10 +161,6 @@ func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion {
return b.datasourceResourceInfo.GroupVersion()
}
func (b *DataSourceAPIBuilder) SetDataSourceCRUDMetrics(datasourceCRUDMetric *prometheus.HistogramVec) {
b.dataSourceCRUDMetric = datasourceCRUDMetric
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&datasourceV0.DataSource{},
@@ -234,9 +218,13 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
if b.configCrudUseNewApis {
legacyStore := &legacyStorage{
datasources: b.datasources,
resourceInfo: &ds,
dsConfigHandlerRequestsDuration: b.dataSourceCRUDMetric,
datasources: b.datasources,
resourceInfo: &ds,
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"}),
}
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter)
if err != nil {
@@ -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)
}
-1
View File
@@ -38,7 +38,6 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui
}
func (b *ServiceAPIBuilder) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
-5
View File
@@ -3,7 +3,6 @@ package apiregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/registry/apis/collections"
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
@@ -34,10 +33,6 @@ var WireSetExts = wire.NewSet(
externalgroupmapping.ProvideNoopTeamGroupsREST,
wire.Bind(new(externalgroupmapping.TeamGroupsHandler), new(*externalgroupmapping.NoopTeamGroupsREST)),
// Auditing Options
auditing.ProvideNoopBackend,
auditing.ProvideNoopPolicyRuleEvaluator,
)
var provisioningExtras = wire.NewSet(
@@ -5,7 +5,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
@@ -17,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -62,11 +60,6 @@ func RegisterAppInstaller(
return installer, nil
}
func (a *AppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
func (a *AppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) rest.Storage {
kind := correlationsV0.CorrelationKind()
gvr := schema.GroupVersionResource{
-8
View File
@@ -6,20 +6,17 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
"github.com/grafana/grafana/apps/playlist/pkg/apis"
playlistv0alpha1 "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
playlistapp "github.com/grafana/grafana/apps/playlist/pkg/app"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
@@ -66,11 +63,6 @@ func RegisterAppInstaller(
return installer, nil
}
func (p *PlaylistAppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
// GetLegacyStorage returns the legacy storage for the playlist app.
func (p *PlaylistAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage {
gvr := playlistv0alpha1.PlaylistKind().GroupVersionResource()
-7
View File
@@ -3,14 +3,12 @@ package quotas
import (
"github.com/grafana/grafana/apps/quotas/pkg/apis"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"k8s.io/apiserver/pkg/authorization/authorizer"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
quotasapp "github.com/grafana/grafana/apps/quotas/pkg/app"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
@@ -24,11 +22,6 @@ type QuotasAppInstaller struct {
cfg *setting.Cfg
}
func (a *QuotasAppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck // not yet migrated to Resource Authorizer
return roleauthorizer.NewRoleAuthorizer()
}
func RegisterAppInstaller(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
+2 -7
View File
@@ -14,7 +14,6 @@ import (
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/grafana/grafana/pkg/expr"
@@ -832,9 +831,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
backend := auditing.ProvideNoopBackend()
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
return nil, err
}
@@ -1492,9 +1489,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
backend := auditing.ProvideNoopBackend()
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
return nil, err
}
@@ -114,8 +114,6 @@ func RegisterAuthorizers(
registrar.Register(gv, authorizer)
logger.Debug("Registered authorizer", "group", gv.Group, "version", gv.Version, "app")
}
} else {
panic("authorizer cannot be nil for api group: " + installer.GroupVersions()[0].Group)
}
}
}
@@ -15,7 +15,6 @@ func TestRegisterAuthorizers(t *testing.T) {
name string
appInstallers []appsdkapiserver.AppInstaller
expectedRegisters int
expectedPanic bool
}{
{
name: "empty installers list",
@@ -31,7 +30,7 @@ func TestRegisterAuthorizers(t *testing.T) {
},
},
},
expectedPanic: true,
expectedRegisters: 0,
},
{
name: "single installer with authorizer provider",
@@ -47,20 +46,6 @@ func TestRegisterAuthorizers(t *testing.T) {
},
expectedRegisters: 1,
},
{
name: "single installer with invalid authorizer provider",
appInstallers: []appsdkapiserver.AppInstaller{
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
groupVersions: []schema.GroupVersion{
{Group: "test.example.com", Version: "v1"},
},
},
mockAuthorizer: nil,
},
},
expectedPanic: true,
},
{
name: "installer with multiple group versions",
appInstallers: []appsdkapiserver.AppInstaller{
@@ -78,7 +63,7 @@ func TestRegisterAuthorizers(t *testing.T) {
expectedRegisters: 3,
},
{
name: "multiple installers with authorizer support",
name: "multiple installers with mixed authorizer support",
appInstallers: []appsdkapiserver.AppInstaller{
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
@@ -88,6 +73,11 @@ func TestRegisterAuthorizers(t *testing.T) {
},
mockAuthorizer: &mockAuthorizer{},
},
&mockAppInstaller{
groupVersions: []schema.GroupVersion{
{Group: "other.example.com", Version: "v1"},
},
},
&mockAppInstallerWithAuth{
mockAppInstaller: &mockAppInstaller{
groupVersions: []schema.GroupVersion{
@@ -98,7 +88,7 @@ func TestRegisterAuthorizers(t *testing.T) {
mockAuthorizer: &mockAuthorizer{},
},
},
expectedRegisters: 3, // 1 from first installer + 2 from second installer
expectedRegisters: 3, // 1 from first installer + 2 from third installer
},
}
@@ -106,13 +96,6 @@ func TestRegisterAuthorizers(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
registrar := &mockAuthorizerRegistrar{}
if tt.expectedPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("%s case did not panic as expected", t.Name())
}
}()
}
RegisterAuthorizers(ctx, tt.appInstallers, registrar)
require.Equal(t, tt.expectedRegisters, len(registrar.registrations))
})
@@ -38,12 +38,12 @@ func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
// Individual services may have explicit implementations
apis := make(map[string]authorizer.Authorizer)
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
authorizers = append(authorizers, &authorizerForAPI{apis})
// org role authorizer is last -- and will return allow for verbs that match expectations
// it is only helpful here for remote APIs in some cloud use-cases.
//nolint:staticcheck // remove once build handler chains are untangled between local and remote APIs handling
// org role is last -- and will return allow for verbs that match expectations
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
// NOTE: role authorizer is now used by some api groups as their specific authorizer
// but there are still some apis not directly registered in the embedded delegate that benefit from including it here
authorizers = append(authorizers, NewRoleAuthorizer())
return &GrafanaAuthorizer{
apis: apis,
@@ -19,7 +19,6 @@ var orgRoleNoneAsViewerAPIGroups = []string{
type roleAuthorizer struct{}
// Deprecated: NewRoleAuthorizer exists for apps that were launched with simplistic authorization requirements. Consider using NewResourceAuthorizer instead.
func NewRoleAuthorizer() *roleAuthorizer {
return &roleAuthorizer{}
}
-12
View File
@@ -12,7 +12,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/audit"
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
"k8s.io/apiserver/pkg/endpoints/responsewriter"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -114,9 +113,6 @@ type service struct {
appInstallers []appsdkapiserver.AppInstaller
builderMetrics *builder.BuilderMetrics
dualWriterMetrics *grafanarest.DualWriterMetrics
auditBackend audit.Backend
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator
}
func ProvideService(
@@ -141,8 +137,6 @@ func ProvideService(
aggregatorRunner aggregatorrunner.AggregatorRunner,
appInstallers []appsdkapiserver.AppInstaller,
builderMetrics *builder.BuilderMetrics,
auditBackend audit.Backend,
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator,
) (*service, error) {
scheme := builder.ProvideScheme()
codecs := builder.ProvideCodecFactory(scheme)
@@ -173,8 +167,6 @@ func ProvideService(
appInstallers: appInstallers,
builderMetrics: builderMetrics,
dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg),
auditBackend: auditBackend,
auditPolicyRuleEvaluator: auditPolicyRuleEvaluator,
}
// This will be used when running as a dskit service
s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer)
@@ -363,10 +355,6 @@ func (s *service) start(ctx context.Context) error {
appinstaller.BuildOpenAPIDefGetter(s.appInstallers),
}
// Auditing Options
serverConfig.AuditBackend = s.auditBackend
serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleEvaluator
// Add OpenAPI specs for each group+version (existing builders)
err = builder.SetupConfig(
s.scheme,
+2 -11
View File
@@ -412,16 +412,11 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro
deletePermanently = true
}
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
namespace := ngmodels.NewNamespace(f)
if err := namespace.ValidateForRuleStorage(); err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
}
if err := srv.checkGroupLimits(ruleGroupConfig); err != nil {
return ErrResp(http.StatusBadRequest, err, "")
}
@@ -846,14 +841,10 @@ func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body a
return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "")
}
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
namespace := ngmodels.NewNamespace(f)
if err := namespace.ValidateForRuleStorage(); err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
}
ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{
User: c.SignedInUser,
@@ -18,7 +18,6 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -1289,64 +1288,4 @@ func TestRouteUpdateNamespaceRules(t *testing.T) {
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
t.Run("should reject update when folder is managed by ManagerKindRepo", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
// Create a managed folder
managedFolder := randFolder()
managedFolder.ManagedBy = utils.ManagerKindRepo
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
// Create some rules in the managed folder
ruleGen := models.RuleGen.With(
models.RuleGen.WithOrgID(orgID),
models.RuleGen.WithNamespaceUID(managedFolder.UID),
)
rules := ruleGen.GenerateManyRef(2)
ruleStore.PutRule(context.Background(), rules...)
permissions := createPermissionsForRules(rules, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, managedFolder.UID)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
// Verify no rules were updated
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
}
func TestRoutePostNameRulesConfig(t *testing.T) {
t.Run("should reject creation when folder is managed by ManagerKindRepo", func(t *testing.T) {
orgID := rand.Int63()
ruleStore := fakes.NewRuleStore(t)
// Create a managed folder
managedFolder := randFolder()
managedFolder.ManagedBy = utils.ManagerKindRepo
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
permissions := map[int64]map[string][]string{
orgID: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(managedFolder.UID): {dashboards.ActionFoldersRead},
},
}
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createService(ruleStore, nil)
response := svc.RoutePostNameRulesConfig(requestCtx, apimodels.PostableRuleGroupConfig{
Name: "test-group",
}, managedFolder.UID)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
})
}
@@ -296,7 +296,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
allowedNamespaces := map[string]string{}
for namespaceUID, folder := range namespaceMap {
// only add namespaces that the user has access to rules in
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.NewNamespace(folder))
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.Namespace(*folder.ToFolderReference()))
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error())
+1 -1
View File
@@ -204,7 +204,7 @@ func IsNonRetryableError(err error) bool {
return false
}
// IsError returns true when Results contains at least one element and all elements are errors
// HasErrors returns true when Results contains at least one element and all elements are errors
func (evalResults Results) IsError() bool {
for _, r := range evalResults {
if r.State != Error {
-15
View File
@@ -24,7 +24,6 @@ import (
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/setting"
@@ -398,20 +397,6 @@ type Namespaced interface {
type Namespace folder.FolderReference
func NewNamespace(f *folder.Folder) Namespace {
return Namespace(*f.ToFolderReference())
}
func (n Namespace) ValidateForRuleStorage() error {
if n.UID == "" {
return fmt.Errorf("cannot store rules in folder without UID")
}
if n.ManagedBy == utils.ManagerKindRepo {
return fmt.Errorf("cannot store rules in folder managed by Git Sync")
}
return nil
}
func (n Namespace) GetNamespaceUID() string {
return n.UID
}
@@ -114,7 +114,7 @@ func (service *AlertRuleService) ListAlertRules(ctx context.Context, user identi
}
folderUIDs := make([]string, 0, len(folders))
for _, f := range folders {
access, err := service.authz.HasAccessInFolder(ctx, user, models.NewNamespace(f))
access, err := service.authz.HasAccessInFolder(ctx, user, models.Namespace(*f.ToFolderReference()))
if err != nil {
return nil, nil, "", err
}
@@ -407,9 +407,6 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil {
return err
}
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), namespaceUID); err != nil {
return err
}
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
query := &models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
@@ -474,10 +471,6 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
return err
}
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), group.FolderUID); err != nil {
return err
}
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 {
return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title)
@@ -1032,7 +1025,6 @@ func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) e
// ensureNamespace ensures that the rule has a valid namespace UID.
// If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error.
// If the folder is managed by a manager, it will also return an error.
func (service *AlertRuleService) ensureNamespace(ctx context.Context, user identity.Requester, orgID int64, namespaceUID string) error {
if namespaceUID == "" {
return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation)
@@ -1045,23 +1037,18 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident
}
// ensure the namespace exists
f, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
_, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
OrgID: orgID,
UID: &namespaceUID,
SignedInUser: user,
})
if err != nil || f == nil {
if err != nil {
if errors.Is(err, dashboards.ErrFolderNotFound) {
return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation)
}
return err
}
// check if the folder is managed by a manager
if err := models.NewNamespace(f).ValidateForRuleStorage(); err != nil {
return fmt.Errorf("%w: %s", models.ErrAlertRuleFailedValidation, err)
}
return nil
}
@@ -16,7 +16,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/db"
@@ -868,27 +867,6 @@ func TestIntegrationAlertRuleService(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(120), rule.IntervalSeconds)
})
t.Run("UpdateRuleGroup should reject when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-update-group"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
err := service.UpdateRuleGroup(context.Background(), u, managedFolderUID, "some-group", 120)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestIntegrationCreateAlertRule(t *testing.T) {
@@ -1188,30 +1166,6 @@ func TestIntegrationCreateAlertRule(t *testing.T) {
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule")
})
t.Run("should reject creation when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
rule := dummyRule("test-managed-folder", orgID)
rule.NamespaceUID = managedFolderUID
_, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestUpdateAlertRule(t *testing.T) {
@@ -1362,36 +1316,6 @@ func TestUpdateAlertRule(t *testing.T) {
require.Equal(t, "nogroup-update-new", updated.Title)
require.Equal(t, originalInterval, updated.IntervalSeconds)
})
t.Run("should reject update when folder is managed by a manager", func(t *testing.T) {
service, ruleStore, provenanceStore, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-update"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
// Create an existing rule
existingRule := dummyRule("test-managed-folder-update", orgID)
existingRule.NamespaceUID = managedFolderUID
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.InsertRule{{AlertRule: existingRule}})
require.NoError(t, err)
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &existingRule, orgID, models.ProvenanceNone))
// Try to update the rule
existingRule.Title = "Updated Title"
_, err = service.UpdateAlertRule(context.Background(), u, existingRule, models.ProvenanceNone)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestDeleteAlertRule(t *testing.T) {
@@ -2130,33 +2054,6 @@ func TestReplaceGroup(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "cannot move rule out of this group")
})
t.Run("should reject replace when folder is managed by a manager", func(t *testing.T) {
service, _, _, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
return true, nil
}
managedFolderUID := "managed-folder-replace"
fs := foldertest.NewFakeService()
fs.AddFolder(&folder.Folder{
OrgID: orgID,
UID: managedFolderUID,
Title: "Managed Folder",
ManagedBy: utils.ManagerKindRepo,
})
service.folderService = fs
group := models.AlertRuleGroup{
Title: "test-group",
FolderUID: managedFolderUID,
Interval: 60,
}
err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone, "")
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
})
}
func TestDeleteRuleGroup(t *testing.T) {
+1 -1
View File
@@ -530,7 +530,7 @@ func (h *RemoteLokiBackend) getFolderUIDsForFilter(ctx context.Context, query mo
uids := make([]string, 0, len(folders))
// now keep only UIDs of folder in which user can read rules.
for _, f := range folders {
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.NewNamespace(f))
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.Namespace(*f.ToFolderReference()))
if err != nil {
return nil, err
}
@@ -261,8 +261,8 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
logger.Info("Starting batched dashboard_uid migration for annotations (newest first)", "batchSize", batchSize)
updateSQL := `UPDATE annotation
SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
AND annotation.id IN (
SELECT id FROM annotation
@@ -285,19 +285,19 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
LIMIT $1
)`
case MySQL:
updateSQL = `UPDATE annotation AS a
JOIN dashboard AS d ON a.dashboard_id = d.id
JOIN (
SELECT id
FROM annotation
WHERE dashboard_uid IS NULL
AND dashboard_id != 0
ORDER BY id DESC
LIMIT ?
) AS batch ON batch.id = a.id
SET a.dashboard_uid = d.uid
WHERE a.dashboard_uid IS NULL
AND a.dashboard_id != 0`
updateSQL = `UPDATE annotation
INNER JOIN dashboard ON annotation.dashboard_id = dashboard.id
SET annotation.dashboard_uid = dashboard.uid
WHERE annotation.dashboard_uid IS NULL
AND annotation.dashboard_id != 0
AND annotation.id IN (
SELECT id FROM (
SELECT id FROM annotation
WHERE dashboard_uid IS NULL AND dashboard_id != 0
ORDER BY id DESC
LIMIT ?
) AS batch
)`
}
updatedTotal := int64(0)
@@ -2,11 +2,8 @@ package migrations
import (
"fmt"
"strings"
"github.com/bwmarrin/snowflake"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util/xorm"
)
func initResourceTables(mg *migrator.Migrator) string {
@@ -207,142 +204,5 @@ func initResourceTables(mg *migrator.Migrator) string {
Name: "IDX_resource_history_key_path",
}))
mg.AddMigration("resource_history key_path backfill", &ResourceHistoryKeyPathBackfillMigration{})
return marker
}
type ResourceHistoryKeyPathBackfillMigration struct {
migrator.MigrationBase
}
func (m *ResourceHistoryKeyPathBackfillMigration) SQL(_ migrator.Dialect) string {
return "resource_history key_path backfill code migration"
}
func (m *ResourceHistoryKeyPathBackfillMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
rows, err := getResourceHistoryRows(sess, mg, resourceHistoryRow{})
if err != nil {
return err
}
for len(rows) > 0 {
if err := updateResourceHistoryKeyPath(sess, rows); err != nil {
return err
}
rows, err = getResourceHistoryRows(sess, mg, rows[len(rows)-1])
if err != nil {
return err
}
}
return nil
}
func updateResourceHistoryKeyPath(sess *xorm.Session, rows []resourceHistoryRow) error {
if len(rows) == 0 {
return nil
}
updates := []resourceHistoryRow{}
for _, row := range rows {
if row.KeyPath == "" {
row.KeyPath = parseKeyPath(row)
updates = append(updates, row)
}
}
if len(updates) == 0 {
return nil
}
guids := ""
setCases := "CASE"
for _, row := range updates {
guids += fmt.Sprintf("'%s',", row.GUID)
setCases += fmt.Sprintf(" WHEN guid = '%s' THEN '%s'", row.GUID, row.KeyPath)
}
guids = strings.TrimRight(guids, ",")
setCases += " ELSE key_path END "
// the query will look like this
// UPDATE resource_history
// SET key_path = CASE
// WHEN guid = '1402de51-669b-4206-8a6c-005a00eee6e3' then 'unified/data/folder.grafana.app/folders/default/cf6lylpvls000c/1998492888241012800~created~'
// WHEN guid = '8842cc56-f22b-45e1-82b1-99759cd443b3' then 'unified/data/dashboard.grafana.app/dashboards/default/adzvfhp/1998492902577144677~created~cf6lylpvls000c'
// ELSE key_path END
// WHERE guid IN ('1402de51-669b-4206-8a6c-005a00eee6e3', '8842cc56-f22b-45e1-82b1-99759cd443b3')
// AND key_path = '';
sql := fmt.Sprintf(`
UPDATE resource_history
SET key_path = %s
WHERE guid IN (%s)
AND key_path = '';
`, setCases, guids)
if _, err := sess.Exec(sql); err != nil {
return err
}
return nil
}
func parseKeyPath(row resourceHistoryRow) string {
var action string
switch row.Action {
case 1:
action = "created"
case 2:
action = "updated"
case 3:
action = "deleted"
}
return fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", row.Group, row.Resource, row.Namespace, row.Name, snowflakeFromRv(row.ResourceVersion), action, row.Folder)
}
func snowflakeFromRv(rv int64) int64 {
return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000)
}
type resourceHistoryRow struct {
GUID string `xorm:"guid"`
Group string `xorm:"group"`
Resource string `xorm:"resource"`
Namespace string `xorm:"namespace"`
Name string `xorm:"name"`
ResourceVersion int64 `xorm:"resource_version"`
Action int64 `xorm:"action"`
Folder string `xorm:"folder"`
KeyPath string `xorm:"key_path"`
}
func getResourceHistoryRows(sess *xorm.Session, mg *migrator.Migrator, continueRow resourceHistoryRow) ([]resourceHistoryRow, error) {
var rows []resourceHistoryRow
cols := fmt.Sprintf(
"%s, %s, %s, %s, %s, %s, %s, %s, %s",
mg.Dialect.Quote("guid"),
mg.Dialect.Quote("group"),
mg.Dialect.Quote("resource"),
mg.Dialect.Quote("namespace"),
mg.Dialect.Quote("name"),
mg.Dialect.Quote("resource_version"),
mg.Dialect.Quote("action"),
mg.Dialect.Quote("folder"),
mg.Dialect.Quote("key_path"))
sql := fmt.Sprintf(`
SELECT %s
FROM resource_history
WHERE (resource_version > %d OR (resource_version = %d AND guid > '%s'))
AND key_path = ''
ORDER BY resource_version ASC, guid ASC
LIMIT 1000;
`, cols, continueRow.ResourceVersion, continueRow.ResourceVersion, continueRow.GUID)
if err := sess.SQL(sql).Find(&rows); err != nil {
return nil, err
}
return rows, nil
}
@@ -3696,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",
@@ -3722,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"
@@ -3861,13 +3849,6 @@
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs"
}
]
},
"warnings": {
"type": "array",
"items": {
"type": "string",
"default": ""
}
}
}
},
@@ -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
+3 -4
View File
@@ -3707,10 +3707,6 @@
"clear": "Clear search and filters",
"text": "No results found for your query"
},
"recently-viewed": {
"empty": "Nothing viewed yet",
"title": "Recently viewed"
},
"restore": {
"all-failed_one": "Failed to restore {{count}} dashboard",
"all-failed_other": "Failed to restore {{count}} dashboards",
@@ -10728,6 +10724,7 @@
"title": "New"
},
"new-dashboard": {
"empty-title": "Empty dashboard",
"title": "New dashboard"
},
"new-folder": {
@@ -11871,6 +11868,7 @@
"title-setting-connection-could-cause-temporary-outage": "Setting up this connection could cause a temporary outage"
},
"getting-started-page": {
"header": "Provisioning",
"subtitle-provisioning-feature": "View and manage your provisioning connections"
},
"git": {
@@ -12636,6 +12634,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "Empty dashboard",
"import": "Import",
"new": "New",
"new-dashboard": "New dashboard",