diff --git a/.github/commands.json b/.github/commands.json index b26f0a37719..003bcda636d 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -501,7 +501,7 @@ }, { "type": "label", - "name": "area/area/expressions/sql", + "name": "area/expressions/sql", "action": "addToProject", "addToProject": { "url": "https://github.com/orgs/grafana/projects/908" diff --git a/.github/pr-commands.json b/.github/pr-commands.json index f9934555782..f97a0738700 100644 --- a/.github/pr-commands.json +++ b/.github/pr-commands.json @@ -413,5 +413,71 @@ ], "action": "updateLabel", "addLabel": "area/panel/table" + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/azuremonitor/**/*", + "pkg/tsdb/azuremonitor/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/graphite/**/*", + "pkg/tsdb/graphite/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/influxdb/**/*", + "pkg/tsdb/influx/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/elasticsearch/**/*", + "pkg/tsdb/elasticsearch/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/cloud-monitoring/**/*", + "pkg/tsdb/cloud-monitoring/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "changedfiles", + "matches": [ + "public/app/plugins/datasource/opentsdb/**/*", + "pkg/tsdb/opentsdb/**/*" + ], + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } } ] diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index c2f22c755fa..06a644f5843 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -16,6 +16,10 @@ on: required: true type: string description: The version of Grafana that is being released (without the `v` prefix)` + target: + required: false + type: string + description: 'Unused: left here for backwards compatibility' changelog: required: false type: boolean diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a269eb40b..27d4f9fdc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ + + +# 12.0.1+security-01 (2025-06-13) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 11.6.2+security-01 (2025-06-13) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 11.5.5+security-01 (2025-06-13) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 11.4.5+security-01 (2025-06-12) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 11.3.7+security-01 (2025-06-12) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 11.2.10+security-01 (2025-06-12) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + + + +# 10.4.19+security-01 (2025-06-12) + +### Bug fixes + +- **Security:** Fixed CVE-2025-3415 + + # 12.0.1 (2025-05-22) diff --git a/apps/advisor/pkg/app/app.go b/apps/advisor/pkg/app/app.go index b647e86a964..ba84a14f7f7 100644 --- a/apps/advisor/pkg/app/app.go +++ b/apps/advisor/pkg/app/app.go @@ -71,7 +71,7 @@ func New(cfg app.Config) (app.App, error) { logger.Error("Error getting requester", "error", err) return } - ctx = identity.WithRequester(context.Background(), requester) + ctx = identity.WithServiceIdentityContext(context.WithoutCancel(ctx), requester.GetOrgID()) err = processCheck(ctx, logger, client, typesClient, req.Object, check) if err != nil { logger.Error("Error processing check", "error", err) @@ -87,7 +87,7 @@ func New(cfg app.Config) (app.App, error) { logger.Error("Error getting requester", "error", err) return } - ctx = identity.WithRequester(context.Background(), requester) + ctx = identity.WithServiceIdentityContext(context.WithoutCancel(ctx), requester.GetOrgID()) err = processCheckRetry(ctx, logger, client, typesClient, req.Object, check) if err != nil { logger.Error("Error processing check retry", "error", err) diff --git a/apps/advisor/pkg/app/checkscheduler/checkscheduler.go b/apps/advisor/pkg/app/checkscheduler/checkscheduler.go index b8bf81299bf..adf0cc1569d 100644 --- a/apps/advisor/pkg/app/checkscheduler/checkscheduler.go +++ b/apps/advisor/pkg/app/checkscheduler/checkscheduler.go @@ -22,10 +22,16 @@ import ( const defaultEvaluationInterval = 7 * 24 * time.Hour // 7 days const defaultMaxHistory = 10 +var ( + waitInterval = 5 * time.Second + waitMaxRetries = 3 +) + // Runner is a "runnable" app used to be able to expose and API endpoint // with the existing checks types. This does not need to be a CRUD resource, but it is // the only way existing at the moment to expose the check types. type Runner struct { + checkRegistry checkregistry.CheckService client resource.Client typesClient resource.Client evaluationInterval time.Duration @@ -41,6 +47,7 @@ func New(cfg app.Config, log logging.Logger) (app.Runnable, error) { if !ok { return nil, fmt.Errorf("invalid config type") } + checkRegistry := specificConfig.CheckRegistry evalInterval, err := getEvaluationInterval(specificConfig.PluginConfig) if err != nil { return nil, err @@ -66,6 +73,7 @@ func New(cfg app.Config, log logging.Logger) (app.Runnable, error) { } return &Runner{ + checkRegistry: checkRegistry, client: client, typesClient: typesClient, evaluationInterval: evalInterval, @@ -85,7 +93,7 @@ func (r *Runner) Run(ctx context.Context) error { } else { // do an initial creation if necessary if lastCreated.IsZero() { - err = r.createChecks(ctx) + err = r.createChecks(ctx, logger) if err != nil { logger.Error("Error creating new check reports", "error", err) } else { @@ -101,7 +109,7 @@ func (r *Runner) Run(ctx context.Context) error { for { select { case <-ticker.C: - err = r.createChecks(ctx) + err = r.createChecks(ctx, logger) if err != nil { logger.Error("Error creating new check reports", "error", err) } @@ -150,12 +158,25 @@ func (r *Runner) checkLastCreated(ctx context.Context, log logging.Logger) (time } // createChecks creates a new check for each check type in the registry. -func (r *Runner) createChecks(ctx context.Context) error { +func (r *Runner) createChecks(ctx context.Context, logger logging.Logger) error { // List existing CheckType objects list, err := r.typesClient.List(ctx, r.namespace, resource.ListOptions{}) if err != nil { return fmt.Errorf("error listing check types: %w", err) } + // This may be run before the check types are registered, so we need to wait for them to be registered. + allChecksRegistered := len(list.GetItems()) == len(r.checkRegistry.Checks()) + retryCount := 0 + for !allChecksRegistered && retryCount < waitMaxRetries { + logger.Error("Waiting for all check types to be registered", "retryCount", retryCount, "waitInterval", waitInterval) + time.Sleep(waitInterval) + list, err = r.typesClient.List(ctx, r.namespace, resource.ListOptions{}) + if err != nil { + return fmt.Errorf("error listing check types: %w", err) + } + allChecksRegistered = len(list.GetItems()) == len(r.checkRegistry.Checks()) + retryCount++ + } // Create checks for each CheckType for _, item := range list.GetItems() { diff --git a/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go b/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go index b699b50e7b7..69b4e809b4c 100644 --- a/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go +++ b/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go @@ -36,7 +36,7 @@ func TestRunner_Run(t *testing.T) { runner := &Runner{ client: mockClient, typesClient: mockTypesClient, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, evaluationInterval: 1 * time.Hour, } @@ -56,10 +56,10 @@ func TestRunner_checkLastCreated_ErrorOnList(t *testing.T) { runner := &Runner{ client: mockClient, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - lastCreated, err := runner.checkLastCreated(context.Background(), logging.DefaultLogger) + lastCreated, err := runner.checkLastCreated(context.Background(), &logging.NoOpLogger{}) assert.Error(t, err) assert.True(t, lastCreated.IsZero()) } @@ -89,10 +89,10 @@ func TestRunner_checkLastCreated_UnprocessedCheck(t *testing.T) { runner := &Runner{ client: mockClient, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - lastCreated, err := runner.checkLastCreated(context.Background(), logging.DefaultLogger) + lastCreated, err := runner.checkLastCreated(context.Background(), &logging.NoOpLogger{}) assert.NoError(t, err) assert.True(t, lastCreated.IsZero()) assert.Equal(t, "check-1", identifier.Name) @@ -104,6 +104,8 @@ func TestRunner_checkLastCreated_UnprocessedCheck(t *testing.T) { } func TestRunner_createChecks_ErrorOnCreate(t *testing.T) { + mockCheckService := &MockCheckService{checks: []checks.Check{&mockCheck{id: "check-1"}}} + mockClient := &MockClient{ createFunc: func(ctx context.Context, id resource.Identifier, obj resource.Object, opts resource.CreateOptions) (resource.Object, error) { return nil, errors.New("create error") @@ -121,16 +123,19 @@ func TestRunner_createChecks_ErrorOnCreate(t *testing.T) { } runner := &Runner{ - client: mockClient, - typesClient: mockTypesClient, - log: logging.DefaultLogger, + checkRegistry: mockCheckService, + client: mockClient, + typesClient: mockTypesClient, + log: &logging.NoOpLogger{}, } - err := runner.createChecks(context.Background()) + err := runner.createChecks(context.Background(), &logging.NoOpLogger{}) assert.Error(t, err) } func TestRunner_createChecks_Success(t *testing.T) { + mockCheckService := &MockCheckService{checks: []checks.Check{&mockCheck{id: "check-1"}}} + mockClient := &MockClient{ createFunc: func(ctx context.Context, id resource.Identifier, obj resource.Object, opts resource.CreateOptions) (resource.Object, error) { return &advisorv0alpha1.Check{}, nil @@ -148,12 +153,13 @@ func TestRunner_createChecks_Success(t *testing.T) { } runner := &Runner{ - client: mockClient, - typesClient: mockTypesClient, - log: logging.DefaultLogger, + checkRegistry: mockCheckService, + client: mockClient, + typesClient: mockTypesClient, + log: &logging.NoOpLogger{}, } - err := runner.createChecks(context.Background()) + err := runner.createChecks(context.Background(), &logging.NoOpLogger{}) assert.NoError(t, err) } @@ -166,10 +172,10 @@ func TestRunner_cleanupChecks_ErrorOnList(t *testing.T) { runner := &Runner{ client: mockClient, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - err := runner.cleanupChecks(context.Background(), logging.DefaultLogger) + err := runner.cleanupChecks(context.Background(), &logging.NoOpLogger{}) assert.Error(t, err) } @@ -187,10 +193,10 @@ func TestRunner_cleanupChecks_WithinMax(t *testing.T) { runner := &Runner{ client: mockClient, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - err := runner.cleanupChecks(context.Background(), logging.DefaultLogger) + err := runner.cleanupChecks(context.Background(), &logging.NoOpLogger{}) assert.NoError(t, err) } @@ -217,9 +223,9 @@ func TestRunner_cleanupChecks_ErrorOnDelete(t *testing.T) { runner := &Runner{ client: mockClient, maxHistory: defaultMaxHistory, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - err := runner.cleanupChecks(context.Background(), logging.DefaultLogger) + err := runner.cleanupChecks(context.Background(), &logging.NoOpLogger{}) assert.ErrorContains(t, err, "delete error") } @@ -253,9 +259,9 @@ func TestRunner_cleanupChecks_Success(t *testing.T) { runner := &Runner{ client: mockClient, maxHistory: defaultMaxHistory, - log: logging.DefaultLogger, + log: &logging.NoOpLogger{}, } - err := runner.cleanupChecks(context.Background(), logging.DefaultLogger) + err := runner.cleanupChecks(context.Background(), &logging.NoOpLogger{}) assert.NoError(t, err) assert.Equal(t, []string{"check-0"}, itemsDeleted) } @@ -334,3 +340,26 @@ func (m *MockClient) Delete(ctx context.Context, identifier resource.Identifier, func (m *MockClient) PatchInto(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions, into resource.Object) error { return m.patchFunc(ctx, identifier, patch, options, into) } + +type MockCheckService struct { + checks []checks.Check +} + +func (m *MockCheckService) Checks() []checks.Check { + return m.checks +} + +type mockCheck struct { + checks.Check + + id string + steps []checks.Step +} + +func (m *mockCheck) ID() string { + return m.id +} + +func (m *mockCheck) Steps() []checks.Step { + return m.steps +} diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 3928838bd33..ccce02984c8 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -692,8 +692,7 @@ VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" VariableHide: *"dontHide" | "hideLabel" | "hideVariable" // Determine the origin of the adhoc variable filter -// Accepted values are `dashboard` (filter originated from dashboard), or `scope` (filter originated from scope). -FilterOrigin: "dashboard" | "scope" +FilterOrigin: "dashboard" // FIXME: should we introduce this? --- Variable value option VariableValueOption: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index a08f1f6eff6..98fc8804de6 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -696,8 +696,7 @@ VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" VariableHide: *"dontHide" | "hideLabel" | "hideVariable" // Determine the origin of the adhoc variable filter -// Accepted values are `dashboard` (filter originated from dashboard), or `scope` (filter originated from scope). -FilterOrigin: "dashboard" | "scope" +FilterOrigin: "dashboard" // FIXME: should we introduce this? --- Variable value option VariableValueOption: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 9fda0a85c6f..5b599cb414e 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1673,14 +1673,14 @@ func NewDashboardAdhocVariableSpec() *DashboardAdhocVariableSpec { // Define the AdHocFilterWithLabels type // +k8s:openapi-gen=true type DashboardAdHocFilterWithLabels struct { - Key string `json:"key"` - Operator string `json:"operator"` - Value string `json:"value"` - Values []string `json:"values,omitempty"` - KeyLabel *string `json:"keyLabel,omitempty"` - ValueLabels []string `json:"valueLabels,omitempty"` - ForceEdit *bool `json:"forceEdit,omitempty"` - Origin *DashboardFilterOrigin `json:"origin,omitempty"` + Key string `json:"key"` + Operator string `json:"operator"` + Value string `json:"value"` + Values []string `json:"values,omitempty"` + KeyLabel *string `json:"keyLabel,omitempty"` + ValueLabels []string `json:"valueLabels,omitempty"` + ForceEdit *bool `json:"forceEdit,omitempty"` + Origin string `json:"origin,omitempty"` // @deprecated Condition *string `json:"condition,omitempty"` } @@ -1691,14 +1691,8 @@ func NewDashboardAdHocFilterWithLabels() *DashboardAdHocFilterWithLabels { } // Determine the origin of the adhoc variable filter -// Accepted values are `dashboard` (filter originated from dashboard), or `scope` (filter originated from scope). // +k8s:openapi-gen=true -type DashboardFilterOrigin string - -const ( - DashboardFilterOriginDashboard DashboardFilterOrigin = "dashboard" - DashboardFilterOriginScope DashboardFilterOrigin = "scope" -) +const DashboardFilterOrigin = "dashboard" // Define the MetricFindValue type // +k8s:openapi-gen=true diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index e9fa97d8017..7a4552f5e0a 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -7,13 +7,17 @@ package apis import ( "fmt" + "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/resource" + v0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" ) +var () + var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", diff --git a/conf/defaults.ini b/conf/defaults.ini index 1b38e925fee..2f3f42361f8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1987,6 +1987,11 @@ provider = static # Default system date format used in time range picker and other places where full time is displayed full_date = YYYY-MM-DD HH:mm:ss +[time_picker] +# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value. +# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}] +quick_ranges = + # Used by graph and other places where we only show small intervals interval_second = HH:mm:ss interval_minute = HH:mm diff --git a/conf/sample.ini b/conf/sample.ini index 27e9148952c..64ce86deac0 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1924,6 +1924,11 @@ default_datasource_uid = # Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc. ;default_timezone = browser +[time_picker] +# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value. +# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}] +;quick_ranges = + [expressions] # Enable or disable the expressions functionality. ;enabled = true diff --git a/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json index 137bbfe04f2..39cc956a7f8 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json @@ -24,19 +24,27 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 1263, + "id": 15116, "links": [], - "liveNow": false, "panels": [ { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -45,7 +53,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -74,10 +83,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -93,14 +104,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -109,7 +129,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -146,10 +167,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -165,14 +188,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -181,7 +213,8 @@ "mode": "percentage", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -218,10 +251,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -237,14 +272,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -253,7 +297,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -275,7 +320,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -314,10 +360,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -333,14 +381,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -394,7 +451,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 } ] } @@ -416,13 +474,15 @@ "showLegend": true }, "mergeValues": true, - "rowHeight": 0.9, + "rowHeight": 0.47, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -438,14 +498,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -454,7 +523,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 } ] } @@ -537,10 +607,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -556,14 +628,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -572,7 +653,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -601,10 +683,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -620,14 +704,23 @@ "type": "state-timeline" }, { - "datasource": { "type": "testdata" }, + "datasource": { + "type": "testdata" + }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { + "axisPlacement": "auto", "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, "lineWidth": 0, "spanNulls": false }, @@ -636,7 +729,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": 0 }, { "color": "red", @@ -665,10 +759,12 @@ "rowHeight": 0.9, "showValue": "auto", "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "12.1.0-pre", "targets": [ { "datasource": { @@ -709,10 +805,192 @@ } ], "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "purple", + "index": 0, + "text": "null" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "red", + "index": 1, + "text": "NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 18 + }, + "id": 12, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [3]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null | NaN value mapping from data", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null+nan", + "result": { + "color": "super-light-red", + "index": 0, + "text": "null + NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 18 + }, + "id": 13, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [1]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null + NaN value mapping from data", + "type": "state-timeline" } ], - "refresh": false, - "schemaVersion": 38, + "preload": false, + "refresh": "", + "schemaVersion": 41, "tags": [ "gdev", "panel-tests", @@ -723,13 +1001,12 @@ "list": [] }, "time": { - "from": "2023-01-26T11:33:55.000Z", - "to": "2023-01-26T14:33:55.000Z" + "from": "2023-01-26T11:29:47.180Z", + "to": "2023-01-26T16:29:39.205Z" }, "timepicker": {}, - "timezone": "", + "timezone": "utc", "title": "StateTimeline - Thresholds & Mappings", "uid": "Kce7z9TVz", - "version": 14, - "weekStart": "" + "version": 13 } diff --git a/docs/sources/administration/migration-guide/cloud-migration-assistant.md b/docs/sources/administration/migration-guide/cloud-migration-assistant.md index 8f009fad5d0..7d2d8d71ed5 100644 --- a/docs/sources/administration/migration-guide/cloud-migration-assistant.md +++ b/docs/sources/administration/migration-guide/cloud-migration-assistant.md @@ -186,6 +186,10 @@ The migration assistant can migrate the majority of Grafana Alerting resources t - Notification policy tree - Notification templates +{{< admonition type="note">}} +The `grafana-default-email` contact point that's provisioned with every new Grafana instance doesn't have a UID by default and won't be migrated unless you edit or update and save it. You do not need to change the contact point for a UID to be generated when saved. +{{< /admonition >}} + This is sufficient to have your Alerting configuration up and running in Grafana Cloud with minimal effort. Migration of Silences is not supported by the migration assistant and needs to be configured manually. Alert History is also not available for migration. diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md index 1c3e06c1243..8e58e619f00 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md @@ -13,41 +13,69 @@ labels: - enterprise - oss menuTitle: MQTT -title: Configure the MQTT notifier for Alerting +title: Configure MQTT notifications weight: 140 +refs: + notification-template-examples: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/configure-notifications/template-notifications/examples/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/examples/ + notification-templates: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/configure-notifications/template-notifications/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/ + configure-contact-points: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/configure-notifications/manage-contact-points/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/ --- -# Configure the MQTT notifier for Alerting +# Configure MQTT notifications -Use the Grafana Alerting - MQTT integration to send notifications to an MQTT broker when your alerts are firing. +Use the MQTT integration in contact points to send alert notifications to your MQTT broker. -## Procedure +## Configure MQTT for a contact point -To configure the MQTT integration for Alerting, complete the following steps. +To create a contact point with MQTT integration, complete the following steps. -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. On the **Contact Points** tab, click **+ Add contact point**. -1. Enter a descriptive name for the contact point. -1. From the Integration list, select **MQTT**. +1. Navigate to **Alerts & IRM** -> **Alerting** -> **Contact points**. +1. Click **+ Add contact point**. +1. Enter a name for the contact point. +1. From the **Integration** list, select **MQTT**. 1. Enter your broker URL in the **Broker URL** field. Supports `tcp`, `ssl`, `mqtt`, `mqtts`, `ws`, `wss` schemes. For example: `tcp://127.0.0.1:1883`. 1. Enter the MQTT topic name in the **Topic** field. -1. In **Optional MQTT settings**, specify additional settings for the MQTT integration if needed. -1. Click **Test** to check that your integration works. +1. (Optional) Configure [additional settings](#optional-settings). +1. Click **Save contact point**. - ** For Grafana Alertmanager only.** +For more details on contact points, including how to test them and enable notifications, refer to [Configure contact points](ref:configure-contact-points). - A test alert notification should be sent to the MQTT broker. +### Required Settings -1. Click **Save** contact point. +| Option | Description | +| ---------- | -------------------------------------------- | +| Broker URL | The URL of the MQTT broker. | +| Topic | The topic to which the message will be sent. | -The integration sends data in JSON format by default. You can change that using **Message format** field in the **Optional MQTT settings** section. There are two supported formats: +### Optional Settings -- **JSON**: Sends the alert notification in JSON format. -- **Text**: Sends the rendered alert notification message in plain text format. +| Option | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Message format | If set to `json` (default), the notification message uses the [default JSON payload](#default-json-payload).
If set to `text`, the notification message is fully customizable. | +| Message | Depends on the **Message format** option.
In `json` format, defines only the `message` field of the [default JSON payload](#default-json-payload).
In `text` format, defines the [entire custom payload](#custom-payload).
This field supports [notification templates](ref:notification-templates). | +| Client ID | The client ID to use when connecting to the MQTT broker. If blank, a random client ID is used. | +| Username | The username to use when connecting to the MQTT broker. | +| Password | The password to use when connecting to the MQTT broker. | +| QoS | The quality of service to use when sending the message. Options are `At most once`, `At least once`, and `Exactly once`. | +| Retain | If set to true, the message will be retained by the broker. | +| TLS | TLS configuration options, including CA certificate, client certificate, and client key, and disable certificate verification. | +| Disable resolved message | Enable this option to prevent notifications when an alert resolves. | -## MQTT JSON payload +## Default JSON payload -If the JSON message format is selected in **Optional MQTT settings**, the payload is sent in the following structure. +If the **Message format** option is `json` (the default), the payload is like this example. ```json { @@ -116,43 +144,42 @@ If the JSON message format is selected in **Optional MQTT settings**, the payloa } ``` -### Payload fields +### Body -Each notification payload contains the following fields. +If the **Message format** option is `json` (the default), the payload contains the following fields. -| Key | Type | Description | -| ----------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | -| receiver | string | Name of the contact point | -| status | string | Current status of the alert, `firing` or `resolved` | -| orgId | number | ID of the organization related to the payload | -| alerts | array of [alert instances](#alert-instance) | Alerts that are triggering | -| groupLabels | object | Labels that are used for grouping, map of string keys to string values | -| commonLabels | object | Labels that all alarms have in common, map of string keys to string values | -| commonAnnotations | object | Annotations that all alarms have in common, map of string keys to string values | -| externalURL | string | External URL to the Grafana instance sending this webhook | -| version | string | Version of the payload | -| groupKey | string | Key that is used for grouping | -| message | string | Rendered message of the alerts | - -### Alert instance - -Each alert instance in the `alerts` array has the following fields. - -| Key | Type | Description | -| ------------ | ------ | ---------------------------------------------------------------------------------- | -| status | string | Current status of the alert, `firing` or `resolved` | -| labels | object | Labels that are part of this alert, map of string keys to string values | -| annotations | object | Annotations that are part of this alert, map of string keys to string values | -| startsAt | string | Start time of the alert | -| endsAt | string | End time of the alert, default value when not resolved is `0001-01-01T00:00:00Z` | -| values | object | Values that triggered the current status | -| generatorURL | string | URL of the alert rule in the Grafana UI | -| fingerprint | string | The labels fingerprint, alarms with the same labels will have the same fingerprint | -| silenceURL | string | URL to silence the alert rule in the Grafana UI | -| dashboardURL | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation | -| panelURL | string | A link to the panel if the alert has a Panel ID annotation | -| imageURL | string | URL of a screenshot of a panel assigned to the rule that created this notification | +| Key | Type | Description | +| ------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `receiver` | string | Name of the contact point | +| `status` | string | Current status of the alert, `firing` or `resolved` | +| `orgId` | number | ID of the organization related to the payload | +| `alerts` | array of [alerts](#alert-object) | Alerts that are triggering | +| `groupLabels` | object | Labels that are used for grouping, map of string keys to string values | +| `commonLabels` | object | Labels that all alarms have in common, map of string keys to string values | +| `commonAnnotations` | object | Annotations that all alarms have in common, map of string keys to string values | +| `externalURL` | string | External URL to the Grafana instance sending this webhook | +| `version` | string | Version of the payload | +| `groupKey` | string | Key that is used for grouping | +| `message` | string | Custom message configured in **Message** (**Optional Settings**).
Supports [notification templates](ref:notification-templates); the output is formatted as a string. | {{< admonition type="note" >}} -Alert rules are not coupled to dashboards anymore. The fields related to dashboards `dashboardId` and `panelId` have been removed. + +When using the `json` **Message format**, only the **message** field of the JSON payload is customizable, and its output is formatted as a string. + +To customize the full payload in text or JSON format, use the `text` format and define a [custom payload](#custom-payload). + {{< /admonition >}} + +### Alert object + +The Alert object represents an alert included in the notification group, as provided by the [`alerts` field](#body). + +{{< docs/shared lookup="alerts/table-for-json-alert-object.md" source="grafana" version="" >}} + +## Custom payload + +When you set the **Message format** option to `text`, you can customize the entire payload of the MQTT message. + +In this mode, the **Message** option defines the entire payload. It supports [notification templates](ref:notification-templates) and can generate notification messages in plain text, JSON, or any custom format. + +For examples of templates that produce plain text or JSON messages, refer to [notification template examples](ref:notification-template-examples). diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md index 53e3025a527..2829b14e235 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md @@ -237,20 +237,7 @@ The following key-value pairs are also included in the JSON payload and can be c The Alert object represents an alert included in the notification group, as provided by the [`alerts` field](#body). -| Key | Type | Description | -| -------------- | ------ | ----------------------------------------------------------------------------------- | -| `status` | string | Current status of the alert, `firing` or `resolved`. | -| `labels` | object | Labels that are part of this alert, map of string keys to string values. | -| `annotations` | object | Annotations that are part of this alert, map of string keys to string values. | -| `startsAt` | string | Start time of the alert. | -| `endsAt` | string | End time of the alert, default value when not resolved is `0001-01-01T00:00:00Z`. | -| `values` | object | Values that triggered the current status. | -| `generatorURL` | string | URL of the alert rule in the Grafana UI. | -| `fingerprint` | string | The labels fingerprint, alarms with the same labels will have the same fingerprint. | -| `silenceURL` | string | URL to silence the alert rule in the Grafana UI. | -| `dashboardURL` | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation. | -| `panelURL` | string | A link to the panel if the alert has a Panel ID annotation. | -| `imageURL` | string | URL of a screenshot of a panel assigned to the rule that created this notification. | +{{< docs/shared lookup="alerts/table-for-json-alert-object.md" source="grafana" version="" >}} ## Custom Payload diff --git a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md index e958f5e1caa..332b65824de 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md +++ b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md @@ -114,7 +114,7 @@ Aggregates time series values within the selected time range into a single numbe Reduce takes one or more time series and transform each series into a single number, which can then be compared in the alert condition. -The following aggregations functions are included: `Min`, `Max`, `Mean`, `Mediam`, `Sum`, `Count`, and `Last`. For more details, refer to the [Reduce documentation](ref:reduce-operation). +The following aggregations functions are included: `Min`, `Max`, `Mean`, `Median`, `Sum`, `Count`, and `Last`. For more details, refer to the [Reduce documentation](ref:reduce-operation). ### Math diff --git a/docs/sources/breaking-changes/_index.md b/docs/sources/breaking-changes/_index.md index fa0628a51d9..aac30357d46 100644 --- a/docs/sources/breaking-changes/_index.md +++ b/docs/sources/breaking-changes/_index.md @@ -13,6 +13,10 @@ weight: 2 # Breaking changes in Grafana +{{< admonition type="note" >}} +As of Grafana v12.0 we no longer publish a dedicated breaking changes page and we, instead, publish breaking changes information in our [What's new](../whatsnew/) page. +{{< /admonition >}} + In some cases, major releases that introduce many new features also introduce breaking changes. These changes are described, along with information about what to do, in the breaking changes pages specific to each release. For our purposes, a breaking change is any change that requires users or operators to do something. This includes: diff --git a/docs/sources/developers/http_api/annotations.md b/docs/sources/developers/http_api/annotations.md index 0fb5c4a5d1a..3210477738d 100644 --- a/docs/sources/developers/http_api/annotations.md +++ b/docs/sources/developers/http_api/annotations.md @@ -54,7 +54,7 @@ Query Parameters: - `to`: epoch datetime in milliseconds. Optional. - `limit`: number. Optional - default is 100. Max limit for results returned. - `alertId`: number. Optional. Find annotations for a specified alert. -- `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard +- `dashboardId`: Deprecated. Use dashboardUID instead. - `dashboardUID`: string. Optional. Find annotations that are scoped to a specific dashboard, when dashboardUID presents, dashboardId would be ignored. - `panelId`: number. Optional. Find annotations that are scoped to a specific panel - `userId`: number. Optional. Find annotations created by a specific user @@ -113,7 +113,7 @@ Content-Type: application/json ## Create Annotation -Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. +Creates an annotation in the Grafana database. The `dashboardUid` and `panelId` fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property. diff --git a/docs/sources/developers/http_api/preferences.md b/docs/sources/developers/http_api/preferences.md index 42847e1e2eb..048f9c38c6f 100644 --- a/docs/sources/developers/http_api/preferences.md +++ b/docs/sources/developers/http_api/preferences.md @@ -21,7 +21,8 @@ title: 'Preferences API' Keys: - **theme** - One of: `light`, `dark`, or an empty string for the default theme -- **homeDashboardId** - The numerical `:id` of a favorited dashboard, default: `0` +- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead. +- **homeDashboardUID**: The `:uid` of a dashboard - **timezone** - One of: `utc`, `browser`, or an empty string for the default Omitting a key will cause the current value to be replaced with the @@ -139,6 +140,7 @@ Content-Type: application/json { "theme": "", "homeDashboardId": 0, + "homeDashboardUID": "", "timezone": "", "weekStart": "", "navbar": { diff --git a/docs/sources/developers/http_api/team.md b/docs/sources/developers/http_api/team.md index db73f9d11f1..f7a501e4df6 100644 --- a/docs/sources/developers/http_api/team.md +++ b/docs/sources/developers/http_api/team.md @@ -477,6 +477,7 @@ Content-Type: application/json { "theme": "", "homeDashboardId": 0, + "homeDashboardUID": "", "timezone": "" } ``` @@ -504,6 +505,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "theme": "dark", "homeDashboardId": 39, + "homeDashboardUID": "jcIIG-07z", "timezone": "utc" } ``` @@ -511,7 +513,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body Schema: - **theme** - One of: `light`, `dark`, or an empty string for the default theme -- **homeDashboardId** - The numerical `:id` of a dashboard, default: `0` +- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead. +- **homeDashboardUID** - The `:uid` of a dashboard - **timezone** - One of: `utc`, `browser`, or an empty string for the default Omitting a key will cause the current value to be replaced with the system default value. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 5442fd9c46a..9d3d20baa10 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -54,6 +54,10 @@ By default, the configuration file is located at `/opt/homebrew/etc/grafana/graf For a Grafana instance installed using Homebrew, edit the `grafana.ini` file directly. Otherwise, add a configuration file named `custom.ini` to the `conf` directory to override the settings defined in `conf/defaults.ini`. +### Grafana Cloud + +There is no local configuration file for Grafana Cloud stacks, but many of these settings are still configurable. To edit configurable settings, open a support ticket. + ## Remove comments in the .ini files Grafana uses semicolons (`;`) to comment out lines in the INI file. @@ -2101,7 +2105,7 @@ Setting `0` means the short links are cleaned up approximately every 10 minutes. A negative value such as `-1` disables expiry. {{< admonition type="caution" >}} -Short links without an expiration increase the size of the database and can't be deleted. +Short links without an expiration increase the size of the database and can't be deleted. Grafana recommends setting a duration based on your specific use case {{< /admonition >}}
@@ -2810,6 +2814,40 @@ Used as the default time zone for user preferences. Can be either `browser` for Set the default start of the week, valid values are: `saturday`, `sunday`, `monday` or `browser` to use the browser locale to define the first day of the week. Default is `browser`. +### `[time_picker]` + +This section controls system-wide defaults for the time picker, such as the default quick ranges. + +#### `quick_ranges` + +Set the default set of quick relative offset time ranges that show up in the right column of the time picker. Each configuration entry must have a `from`, `to`, and `display` field. Any configuration for this field must be in valid JSON format made up of a list of quick range configurations. + +The `from` and `to` fields should be valid relative time ranges. For more information the relative time formats, refer to [Time units and relative ranges.](/docs/grafana//dashboards/use-dashboards/#time-units-and-relative-ranges). The `from` field is required, but omitting `to` will result in the `from` value being used in both fields. + +If no configuration is provided, the default time ranges will be used. + +For example: + +```ini +[time_picker] +quick_ranges = [ + { + "display": "Last 5 minutes", + "from": "now-5m", + "to": "now", + }, + { + "display": "Yesterday", + "from": "now-1d/d", + }, + { + "display": "Today so far", + "from": "now/d", + "to": "now", + } +] +``` + ### `[expressions]` #### `enabled` diff --git a/docs/sources/shared/alerts/table-for-json-alert-object.md b/docs/sources/shared/alerts/table-for-json-alert-object.md new file mode 100644 index 00000000000..a4f53e2dccd --- /dev/null +++ b/docs/sources/shared/alerts/table-for-json-alert-object.md @@ -0,0 +1,18 @@ +--- +title: 'JSON alert object' +--- + +| Key | Type | Description | +| -------------- | ------ | ----------------------------------------------------------------------------------- | +| `status` | string | Current status of the alert, `firing` or `resolved`. | +| `labels` | object | Labels that are part of this alert, map of string keys to string values. | +| `annotations` | object | Annotations that are part of this alert, map of string keys to string values. | +| `startsAt` | string | Start time of the alert. | +| `endsAt` | string | End time of the alert, default value when not resolved is `0001-01-01T00:00:00Z`. | +| `values` | object | Values that triggered the current status. | +| `generatorURL` | string | URL of the alert rule in the Grafana UI. | +| `fingerprint` | string | The labels fingerprint, alarms with the same labels will have the same fingerprint. | +| `silenceURL` | string | URL to silence the alert rule in the Grafana UI. | +| `dashboardURL` | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation. | +| `panelURL` | string | A link to the panel if the alert has a Panel ID annotation. | +| `imageURL` | string | URL of a screenshot of a panel assigned to the rule that created this notification. | diff --git a/go.mod b/go.mod index 6f85332903a..b5d8de4cb23 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250605155607-02235095d018 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index e8c24ae49bc..2d4348b27c0 100644 --- a/go.sum +++ b/go.sum @@ -1569,8 +1569,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20250605155607-02235095d018 h1:sGEBflMw3DUsfV4SCl1at+QRwGi5h29iiAuk8ZZn5Mw= -github.com/grafana/alerting v0.0.0-20250605155607-02235095d018/go.mod h1:e2nDocz4yTgLYGJFMrlL3DTjdMTEYPABorKc191b8/w= +github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872 h1:bhUBPxMHtQB+AJaPcASElgwC7WFjWQvEiD3FoMEAv0E= +github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872/go.mod h1:VANfU4LMnOR4Gv4hkCN9fGyQ/8Lf2DKOvzG70vI2IS4= github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb h1:oTl2j6/4miQUYmXANp2pBuYCWA5f8NVYFfCWpczpFso= github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us= github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM= diff --git a/jest.config.js b/jest.config.js index 34c7247ca10..a57ea5c2067 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,6 +22,8 @@ const esModules = [ 'vscode-languageserver-types', '@bsull/augurs', 'react-data-grid', + '@grafana/llm', + 'pkce-challenge', ].join('|'); module.exports = { diff --git a/package.json b/package.json index 3c0e0c45d29..047dc634449 100644 --- a/package.json +++ b/package.json @@ -280,14 +280,14 @@ "@grafana/google-sdk": "0.1.2", "@grafana/i18n": "workspace:*", "@grafana/lezer-logql": "0.2.7", - "@grafana/llm": "0.19.2", + "@grafana/llm": "0.22.0", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/plugin-ui": "0.10.6", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.19.0", - "@grafana/scenes-react": "^6.19.0", + "@grafana/scenes": "^6.20.1", + "@grafana/scenes-react": "^6.20.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -306,10 +306,10 @@ "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.34.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.25", - "@react-aria/focus": "3.20.3", - "@react-aria/overlays": "3.27.1", - "@react-aria/utils": "3.29.0", + "@react-aria/dialog": "3.5.27", + "@react-aria/focus": "3.20.5", + "@react-aria/overlays": "3.27.3", + "@react-aria/utils": "3.29.1", "@react-awesome-query-builder/ui": "6.6.15", "@reduxjs/toolkit": "2.5.1", "@visx/event": "3.12.0", @@ -368,7 +368,7 @@ "nanoid": "^5.0.9", "node-forge": "^1.3.1", "ol": "7.4.0", - "ol-ext": "4.0.31", + "ol-ext": "4.0.32", "pluralize": "^8.0.0", "prismjs": "1.30.0", "rc-slider": "11.1.8", diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 31980da5f3a..1d88fe53f76 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -33,6 +33,10 @@ "./unstable": { "import": "./src/unstable.ts", "require": "./src/unstable.ts" + }, + "./testing": { + "import": "./src/testing.ts", + "require": "./src/testing.ts" } }, "scripts": { diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts similarity index 100% rename from packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx rename to packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts diff --git a/packages/grafana-alerting/src/testing.ts b/packages/grafana-alerting/src/testing.ts new file mode 100644 index 00000000000..3de18f49993 --- /dev/null +++ b/packages/grafana-alerting/src/testing.ts @@ -0,0 +1,6 @@ +// export MSW handlers for testing +export * from './grafana/api/v0alpha1/mocks/handlers'; + +// export mocks and factories +export * from './grafana/api/v0alpha1/mocks/fakes/common'; +export * from './grafana/api/v0alpha1/mocks/fakes/Receivers'; diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts index 0515131be30..956dc272604 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts @@ -142,20 +142,20 @@ describe('align frames', () => { { name: 'gender', type: FieldType.string, - values: ['NON-BINARY', 'MALE', 'MALE', 'FEMALE', 'FEMALE', 'NON-BINARY'], + values: ['NON-BINARY', 'MALE', 'MALE', 'FEMALE', 'FEMALE', 'NON-BINARY', 'COW'], }, { name: 'day', type: FieldType.string, - values: ['Wednesday', 'Tuesday', 'Monday', 'Wednesday', 'Tuesday', 'Monday'], + values: ['Wednesday', 'Tuesday', 'Monday', 'Wednesday', 'Tuesday', 'Monday', 'Monday'], }, - { name: 'count', type: FieldType.number, values: [18, 72, 13, 17, 71, 7] }, + { name: 'count', type: FieldType.number, values: [18, 72, 13, 17, 71, 7, 1] }, ], }); const tableData2 = toDataFrame({ fields: [ - { name: 'gender', type: FieldType.string, values: ['MALE', 'NON-BINARY', 'FEMALE'] }, - { name: 'count', type: FieldType.number, values: [103, 95, 201] }, + { name: 'gender', type: FieldType.string, values: ['MALE', 'NON-BINARY', 'FEMALE', 'DOG'] }, + { name: 'count', type: FieldType.number, values: [103, 95, 201, 6] }, ], }); @@ -181,6 +181,8 @@ describe('align frames', () => { "FEMALE", "FEMALE", "NON-BINARY", + "COW", + "DOG", ], }, { @@ -192,6 +194,8 @@ describe('align frames', () => { "Wednesday", "Tuesday", "Monday", + "Monday", + null, ], }, { @@ -203,6 +207,8 @@ describe('align frames', () => { 17, 71, 7, + 1, + null, ], }, { @@ -214,6 +220,8 @@ describe('align frames', () => { 201, 201, 95, + null, + 6, ], }, ] diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index 98f28ff9fce..ee621286516 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -167,10 +167,6 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { const nullModes: JoinNullMode[][] = []; const allData: AlignedData[] = []; const originalFields: Field[] = []; - // store frame field order for tabular data join - const originalFieldsOrderByFrame: number[][] = []; - // all other fields that are not the join on are in the 1+ position (join is always the 0) - let fieldsOrder = 1; const joinFieldMatcher = getJoinMatcher(options); for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) { @@ -183,7 +179,6 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { const nullModesFrame: JoinNullMode[] = [NULL_REMOVE]; let join: Field | undefined = undefined; let fields: Field[] = []; - let frameFieldsOrder = []; for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { const field = frame.fields[fieldIndex]; @@ -243,21 +238,16 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { // clear field displayName state delete field.state?.displayName; } - // store frame field order for tabular data join - frameFieldsOrder.push(fieldsOrder); - fieldsOrder++; } - // store frame field order for tabular data join - originalFieldsOrderByFrame.push(frameFieldsOrder); allData.push(a); } let joined: Array> = []; if (options.mode === JoinMode.outerTabular) { - joined = joinOuterTabular(allData, originalFieldsOrderByFrame, originalFields.length, nullModes); + joined = joinTabular(allData, true); } else if (options.mode === JoinMode.inner) { - joined = joinInner(allData); + joined = joinTabular(allData); } else { joined = join(allData, nullModes, options.mode); } @@ -272,165 +262,201 @@ export function joinDataFrames(options: JoinOptions): DataFrame | undefined { }; } -// The following full outer join allows for multiple/duplicated joined fields values where as the performant join from uplot creates a unique set of field values to be joined on -// http://www.silota.com/docs/recipes/sql-join-tutorial-javascript-examples.html -// The frame field value which is used join on is sorted to the 0 position of each table data in both tables and nullModes -// (not sure if we need nullModes) for nullModes, the field to join on is given NULL_REMOVE and all other fields are given NULL_EXPAND -function joinOuterTabular( - tables: AlignedData[], - originalFieldsOrderByFrame: number[][], - numberOfFields: number, - nullModes?: number[][] -) { - // we will iterate through all frames and check frames for matches preventing duplicates. - // we will store each matched frame "row" or field values at the same index in the following hash. - let duplicateHash: { [key: string]: Array } = {}; - - // iterate through the tables (frames) - // for each frame we get the field data where the data in the 0 pos is the value to join on - for (let tableIdx = 0; tableIdx < tables.length; tableIdx++) { - // the table (frame) to check for matches in other tables - let table = tables[tableIdx]; - // the field value to join on (the join value is always in the 0 position) - let joinOnTableField = table[0]; - - // now we iterate through the other table (frame) data to look for matches - for (let otherTablesIdx = 0; otherTablesIdx < tables.length; otherTablesIdx++) { - // do not match on the same table - if (otherTablesIdx === tableIdx) { - continue; - } - - let otherTable = tables[otherTablesIdx]; - let otherTableJoinOnField = otherTable[0]; - - // iterate through the field to join on from the first table - for ( - let joinTableFieldValuesIdx = 0; - joinTableFieldValuesIdx < joinOnTableField.length; - joinTableFieldValuesIdx++ - ) { - // create the joined data - // this has the orignalFields length and should start out undefined - // joined row + number of other fields in each frame - // the order of each field is important in how we - // 1 check for duplicates - // 2 transform the row back into fields for the joined frame - // 3 when there is no match for the row we keep the vals undefined - const tableJoinOnValue = joinOnTableField[joinTableFieldValuesIdx]; - const allOtherFields = numberOfFields - 1; - let joinedRow: Array = [tableJoinOnValue].concat(new Array(allOtherFields)); - - let tableFieldValIdx = 0; - for (let fieldsIdx = 1; fieldsIdx < table.length; fieldsIdx++) { - const joinRowIdx = originalFieldsOrderByFrame[tableIdx][tableFieldValIdx]; - joinedRow[joinRowIdx] = table[fieldsIdx][joinTableFieldValuesIdx]; - tableFieldValIdx++; - } - - for (let otherTableValuesIdx = 0; otherTableValuesIdx < otherTableJoinOnField.length; otherTableValuesIdx++) { - if (joinOnTableField[joinTableFieldValuesIdx] === otherTableJoinOnField[otherTableValuesIdx]) { - let tableFieldValIdx = 0; - for (let fieldsIdx = 1; fieldsIdx < otherTable.length; fieldsIdx++) { - const joinRowIdx = originalFieldsOrderByFrame[otherTablesIdx][tableFieldValIdx]; - joinedRow[joinRowIdx] = otherTable[fieldsIdx][otherTableValuesIdx]; - tableFieldValIdx++; - } - - break; - } - } - - // prevent duplicates by entering rows in a hash where keys are the rows - duplicateHash[JSON.stringify(joinedRow)] = joinedRow; - } - } - } - - // transform the joined rows into data for a dataframe - let data: Array> = []; - for (let field = 0; field < numberOfFields; field++) { - data.push(new Array(0)); - } - - for (let key in duplicateHash) { - const row = duplicateHash[key]; - - for (let valIdx = 0; valIdx < row.length; valIdx++) { - data[valIdx].push(row[valIdx]); - } - } - - return data; -} - /** - * This function performs a sql-style inner join on tabular data; - * it will combine records from two tables whenever there are matching - * values in a field common to both tables. - * - * NOTE: This function implicitly assumes that the first array in each AlignedData - * contains the values to join on. It doesn't explicitly specify a column field to join on, - * but rather uses the 0th position of the arrays (AlignedData[0]) to determine the joining keys. - * Then, when processing the tables, the function iterates over the values in the `xValues` - * (the joining keys) array and checks if the current row `currentRow` already includes the value. - * If a matching value is found, it joins the corresponding values from the remaining arrays `yValues` - * (all other non-joining key arrays) to create a new row in the joined table. - * - * @param {AlignedData[]} tables - The tables to join. - * - * @returns {Array>} The joined tables as an array of arrays, where each array represents a row in the joined table. + * SQL-style join of tables, using the first column in each */ -function joinInner(tables: AlignedData[]): Array> { - const joinedTables: Array> = []; +function joinTabular(tables: AlignedData[], outer = false) { + // console.time('joinTabular'); - // Recursive function to perform the inner join. - const joinTables = ( - currentTables: AlignedData[], - currentIndex: number, - currentRow: Array - ) => { - if (currentIndex === currentTables.length) { - // Base case: all tables have been joined, add the current row to the final result. - joinedTables.push(currentRow); - return; + let ltable = tables[0]; + let lfield = ltable[0]; + + // iterate tables, merging right table with left, with the result becoming the new left + // rinse and repeat for each tables in the array + for (let ti = 1; ti < tables.length; ti++) { + let rtable = tables[ti]; + let rfield = rtable[0]; + + /** + * Build an inverted index of the right table's join column like { "foo": [1,2,3], "bar": [7,12], ... } + * where the keys are unique values and the arrays are indices where these values were found + */ + // console.time('index right'); + let index: Record = {}; + + for (let i = 0; i < rfield.length; i++) { + let val = rfield[i]; + + let idxs = index[val]; + + if (idxs == null) { + idxs = index[val] = []; + } + + idxs.push(i); } + // console.timeEnd('index right'); - const currentTable = currentTables[currentIndex]; - const [xValues, ...yValues] = currentTable; + /** + * Loop over the left table's join column and match each non-null value to the right index, + * copying the matched ridxs array into new matched list, like [33, [45,79,233]], where first + * value is left idx and second value is right idxs + * + * Also keep track of unmatched or null left values for outer join, since we'll need to include these + */ + let matchedKeys = new Set(); + let unmatchedLeft = []; + let unmatchedRight = []; - for (let i = 0; i < xValues.length; i++) { - const value = xValues[i]; + // console.time('match left'); + let matched: Array<[lidx: number, ridxs: number[]]> = []; - if (currentIndex === 0 || currentRow.includes(value)) { - const newRow = [...currentRow]; + // count of total number of output rows, so we can + // pre-allocate the final array size during materialization + let count = 0; - if (currentIndex === 0) { - newRow.push(value); + for (let i = 0; i < lfield.length; i++) { + let v = lfield[i]; + + if (v != null) { + let idxs = index[v]; + + if (idxs != null) { + matched.push([i, idxs]); + count += idxs.length; + outer && matchedKeys.add(v); + } else if (outer) { + unmatchedLeft.push(i); } - - for (let j = 0; j < yValues.length; j++) { - newRow.push(yValues[j][i]); - } - - // Recursive call for the next table - joinTables(currentTables, currentIndex + 1, newRow); + } else if (outer) { + unmatchedLeft.push(i); } } - }; + count += unmatchedLeft.length; + // console.timeEnd('match left'); - // Start the recursive join process. - joinTables(tables, 0, []); + /** + * For outer joins, also loop over the right index to record unmatched values + */ + // console.time('unmatched right'); + if (outer) { + for (let k in index) { + if (!matchedKeys.has(k)) { + unmatchedRight.push(...index[k]); + } + } + count += unmatchedRight.length; + } + // console.timeEnd('unmatched right'); - // Check if joinedTables is empty before transposing. No need to transpose if there are no joined tables. - if (joinedTables.length === 0) { - const fieldCount = tables.reduce((count, table) => count + (table.length - 1), 1); - return Array.from({ length: fieldCount }, () => []); + /** + * Now we can use matched, unmatchedLeft, unmatchedRight, ltable, and rtable to assemble the final table + * Instead of using 3-deep nested loops, we eliminate the loops over the known column structure + * For this we compile a new function using the schemas from both tables, and filling that struct by looping + * over the matched lookup array, then appending the unmatched left rows (and null-filling the right values), + * then appending the unmatched right rows (and null-filling the left values). + * + * The assembled function looks something like this when joining 2-col left + 2-col right: + * + * function anonymous(matched, unmatchedLeft, unmatchedRight, ltable, rtable) { + * const joined = [Array(99991),Array(99991),Array(99991)]; + * + * let rowIdx = 0; + * + * for (let i = 0; i < matched.length; i++) { + * let [lidx, ridxs] = matched[i]; + * + * for (let j = 0; j < ridxs.length; j++, rowIdx++) { + * let ridx = ridxs[j]; + * joined[0][rowIdx] = ltable[0][lidx]; + * joined[1][rowIdx] = ltable[1][lidx]; + * joined[2][rowIdx] = rtable[1][ridx]; + * } + * } + * + * for (let i = 0; i < unmatchedLeft.length; i++, rowIdx++) { + * let lidx = unmatchedLeft[i]; + * joined[0][rowIdx] = ltable[0][lidx]; + * joined[1][rowIdx] = ltable[1][lidx]; + * joined[2][rowIdx] = null; + * } + * + * for (let i = 0; i < unmatchedRight.length; i++, rowIdx++) { + * let ridx = unmatchedRight[i]; + * joined[0][rowIdx] = rtable[0][ridx]; + * joined[1][rowIdx] = null; + * joined[2][rowIdx] = rtable[1][ridx]; + * } + * + * return joined; + * } + */ + // console.time('materialize'); + let outFieldsTpl = Array.from({ length: ltable.length + rtable.length - 1 }, () => `Array(${count})`).join(','); + let copyLeftRowTpl = ltable.map((c, i) => `joined[${i}][rowIdx] = ltable[${i}][lidx]`).join(';'); + // (skips join field in right table) + let copyRightRowTpl = rtable + .slice(1) + .map((c, i) => `joined[${ltable.length + i}][rowIdx] = rtable[${i + 1}][ridx]`) + .join(';'); + + // for outer joins, when we null-fill the left row values, we still populate the first (join) column + // with the right row's join column value, rather than omitting it as we do for matched left/right where + // that value is already filled by the left row + let nullLeftRowTpl = ltable + .map((c, i) => `joined[${i}][rowIdx] = ${i === 0 ? `rtable[${i}][ridx]` : `null`}`) + .join(';'); + // (skips join field in right table) + let nullRightRowTpl = rtable.slice(1).map((c, i) => `joined[${ltable.length + i}][rowIdx] = null`); + + let materialize = new Function( + 'matched', + 'unmatchedLeft', + 'unmatchedRight', + 'ltable', + 'rtable', + ` + const joined = [${outFieldsTpl}]; + + let rowIdx = 0; + + for (let i = 0; i < matched.length; i++) { + let [lidx, ridxs] = matched[i]; + + for (let j = 0; j < ridxs.length; j++, rowIdx++) { + let ridx = ridxs[j]; + ${copyLeftRowTpl}; + ${copyRightRowTpl}; + } + } + + for (let i = 0; i < unmatchedLeft.length; i++, rowIdx++) { + let lidx = unmatchedLeft[i]; + ${copyLeftRowTpl}; + ${nullRightRowTpl}; + } + + for (let i = 0; i < unmatchedRight.length; i++, rowIdx++) { + let ridx = unmatchedRight[i]; + ${nullLeftRowTpl}; + ${copyRightRowTpl}; + } + + return joined; + ` + ); + + let joined = materialize(matched, unmatchedLeft, unmatchedRight, ltable, rtable); + // console.timeEnd('materialize'); + + ltable = joined; + lfield = ltable[0]; } - // Transpose the joined tables to get the desired output format. - // This essentially flips the rows and columns back to the stucture of the original `tables`. - return joinedTables[0].map((_, colIndex) => joinedTables.map((row) => row[colIndex])); + // console.timeEnd('joinTabular'); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ltable as Array>; } //-------------------------------------------------------------------------------- diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index e8fa818a250..a52d60c7e27 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -9,6 +9,7 @@ import { NavLinkDTO } from './navModel'; import { OrgRole } from './orgs'; import { PanelPluginMeta } from './panel'; import { GrafanaTheme } from './theme'; +import { TimeOption } from './time'; /** * Describes the build information that will be available via the Grafana configuration. @@ -240,6 +241,7 @@ export interface GrafanaConfig { reportingStaticContext?: Record; exploreDefaultTimeOffset?: string; exploreHideLogsDownload?: boolean; + quickRanges?: TimeOption[]; // The namespace to use for kubernetes apiserver requests namespace: string; diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 4a0f8adbe58..e8b6ca13f7b 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -54,7 +54,7 @@ export interface FieldConfig { description?: string; /** - * An explict path to the field in the datasource. When the frame meta includes a path, + * An explicit path to the field in the datasource. When the frame meta includes a path, * This will default to `${frame.meta.path}/${field.name} * * When defined, this value can be used as an identifier within the datasource scope, and @@ -158,7 +158,7 @@ export interface Field { /** * When type === FieldType.Time, this can optionally store - * the nanosecond-precison fractions as integers between + * the nanosecond-precision fractions as integers between * 0 and 999999. */ nanos?: number[]; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5b3213e4932..7104c35e4bc 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -954,10 +954,6 @@ export interface FeatureToggles { */ alertingRuleRecoverDeleted?: boolean; /** - * Support Application Signals queries in the X-Ray datasource - */ - xrayApplicationSignals?: boolean; - /** * use multi-tenant path for awsTempCredentials */ multiTenantTempCredentials?: boolean; diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 60a5c6a6026..9265c5f9f2c 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -54,6 +54,7 @@ export interface AdHocVariableFilter { operator: string; value: string; values?: string[]; + origin?: 'dashboard' | string; /** @deprecated */ condition?: string; } diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts index 90686b52895..e03f0ad2585 100644 --- a/packages/grafana-data/src/utils/valueMappings.test.ts +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -281,3 +281,95 @@ describe('isNumeric', () => { expect(isNumeric(value)).toEqual(expected); }); }); + +describe('null and NaN special mapping', () => { + it('should return null for NaN', () => { + const value = Number.NaN; + expect( + getValueMappingResult( + [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NullAndNaN, + result: { text: 'it is null or nan' }, + }, + }, + ], + value + ) + ).toEqual({ text: 'it is null or nan' }); + }); + + it('should return null for null', () => { + const value = null; + expect( + getValueMappingResult( + [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NullAndNaN, + result: { text: 'it is null or nan' }, + }, + }, + ], + value + ) + ).toEqual({ text: 'it is null or nan' }); + }); + + it('should return null for undefined', () => { + const value = undefined; + expect( + getValueMappingResult( + [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NullAndNaN, + result: { text: 'it is null or nan' }, + }, + }, + ], + value + ) + ).toEqual({ text: 'it is null or nan' }); + }); + + it('should return null for numeric non-NaN', () => { + const value = 42; + expect( + getValueMappingResult( + [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NullAndNaN, + result: { text: 'it is null or nan' }, + }, + }, + ], + value + ) + ).toBeNull(); + }); + + it('should return null for string', () => { + const value = 'foo'; + expect( + getValueMappingResult( + [ + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NullAndNaN, + result: { text: 'it is null or nan' }, + }, + }, + ], + value + ) + ).toBeNull(); + }); +}); diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx index 97a434172fd..94fcfa71a1c 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx @@ -4,8 +4,9 @@ import { useRef, useCallback } from 'react'; import { createDataFrame, createTheme } from '@grafana/data'; +import { FlameGraphDataContainer } from './FlameGraph/dataTransform'; import { data } from './FlameGraph/testData/dataNestedSet'; -import FlameGraphContainer from './FlameGraphContainer'; +import FlameGraphContainer, { labelSearch } from './FlameGraphContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; jest.mock('react-use', () => ({ @@ -99,21 +100,117 @@ describe('FlameGraphContainer', () => { render(); // Checking for presence of this function before filter - const matchingText = 'net/http.HandlerFunc.ServeHTTP'; + const matchingText1 = 'net/http.HandlerFunc.ServeHTTP'; + const matchingText2 = 'runtime.gcBgMarkWorker'; const nonMatchingText = 'runtime.systemstack'; - expect(screen.queryAllByText(matchingText).length).toBe(1); + expect(screen.queryAllByText(matchingText1).length).toBe(1); + expect(screen.queryAllByText(matchingText2).length).toBe(1); expect(screen.queryAllByText(nonMatchingText).length).toBe(1); // Apply the filter - const searchInput = await screen.getByPlaceholderText('Search...'); - await userEvent.type(searchInput, 'Handler serve'); + const searchInput = screen.getByPlaceholderText('Search...'); + await userEvent.type(searchInput, 'Handler serve,gcBgMarkWorker'); // We have to wait for filter to take effect await waitFor(() => { expect(screen.queryAllByText(nonMatchingText).length).toBe(0); }); // Check we didn't lose the one that should match - expect(screen.queryAllByText(matchingText).length).toBe(1); + expect(screen.queryAllByText(matchingText1).length).toBe(1); + expect(screen.queryAllByText(matchingText2).length).toBe(1); + }); +}); + +describe('labelSearch', () => { + let container: FlameGraphDataContainer; + + beforeEach(() => { + const df = createDataFrame(data); + df.meta = { + custom: { + ProfileTypeID: 'cpu:foo:bar', + }, + }; + + container = new FlameGraphDataContainer(df, { collapsing: false }); + }); + + describe('fuzzy', () => { + it('single term', () => { + const search = 'test pkg'; + let found = labelSearch(search, container); + expect(found.size).toBe(45); + }); + + it('multiple terms', () => { + const search = 'test pkg,compress'; + let found = labelSearch(search, container); + expect(found.size).toBe(107); + }); + + it('falls back to fuzzy with malformed regex', () => { + const search = 'deduplicatingSlice[.'; + let found = labelSearch(search, container); + expect(found.size).toBe(1); + }); + + it('no results', () => { + const search = 'term_not_found'; + let found = labelSearch(search, container); + expect(found.size).toBe(0); + }); + }); + + describe('regex', () => { + it('single pattern', () => { + const term = '\\d$'; + let found = labelSearch(term, container); + expect(found.size).toBe(61); + }); + + it('multiple patterns', () => { + const term = '\\d$,^go'; + let found = labelSearch(term, container); + expect(found.size).toBe(62); + }); + + it('no results', () => { + const term = 'pattern_not_found'; + let found = labelSearch(term, container); + expect(found.size).toBe(0); + }); + }); + + describe('fuzzy and regex', () => { + it('regex found, fuzzy found', () => { + const term = '\\d$,test pkg'; + let found = labelSearch(term, container); + expect(found.size).toBe(98); + }); + + it('regex not found, fuzzy found', () => { + const term = 'not_found_suffix$,test pkg'; + let found = labelSearch(term, container); + expect(found.size).toBe(45); + }); + + it('regex found, fuzzy not found', () => { + const term = '\\d$,not_found_fuzzy'; + let found = labelSearch(term, container); + expect(found.size).toBe(61); + }); + + it('regex not found, fuzzy not found', () => { + const term = 'not_found_suffix$,not_found_fuzzy'; + let found = labelSearch(term, container); + expect(found.size).toBe(0); + }); + + it('does not match empty terms', () => { + const search = ',,,,,'; + let found = labelSearch(search, container); + expect(found.size).toBe(0); + }); }); }); diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 4d23da838e2..706702142db 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -317,26 +317,71 @@ function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { /** * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. */ -function useLabelSearch( +export function useLabelSearch( search: string | undefined, data: FlameGraphDataContainer | undefined ): Set | undefined { return useMemo(() => { - if (search && data) { - const foundLabels = new Set(); - let idxs = ufuzzy.filter(data.getUniqueLabels(), search); + if (!search || !data) { + // In this case undefined means there was no search so no attempt to + // highlighting anything should be made. + return undefined; + } - if (idxs) { - for (let idx of idxs) { - foundLabels.add(data.getUniqueLabels()[idx]); - } + return labelSearch(search, data); + }, [search, data]); +} + +export function labelSearch(search: string, data: FlameGraphDataContainer): Set { + const foundLabels = new Set(); + const terms = search.split(','); + + const regexFilter = (labels: string[], pattern: string): boolean => { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (e) { + return false; + } + + let foundMatch = false; + for (let label of labels) { + if (!regex.test(label)) { + continue; } - return foundLabels; + foundLabels.add(label); + foundMatch = true; } - // In this case undefined means there was no search so no attempt to highlighting anything should be made. - return undefined; - }, [search, data]); + return foundMatch; + }; + + const fuzzyFilter = (labels: string[], term: string): boolean => { + let idxs = ufuzzy.filter(labels, term); + if (!idxs) { + return false; + } + + let foundMatch = false; + for (let idx of idxs) { + foundLabels.add(labels[idx]); + foundMatch = true; + } + return foundMatch; + }; + + for (let term of terms) { + if (!term) { + continue; + } + + const found = regexFilter(data.getUniqueLabels(), term); + if (!found) { + fuzzyFilter(data.getUniqueLabels(), term); + } + } + + return foundLabels; } function getStyles(theme: GrafanaTheme2) { diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx index 6a5f3f25c05..0a737a95fcc 100644 --- a/packages/grafana-i18n/src/i18n.tsx +++ b/packages/grafana-i18n/src/i18n.tsx @@ -11,6 +11,17 @@ import { ResourceLoader, Resources, TFunction, TransProps, TransType } from './t let tFunc: I18NextTFunction | undefined; let transComponent: TransType; +function initTFuncAndTransComponent({ id, ns }: { id?: string; ns?: string[] } = {}) { + if (id) { + tFunc = getI18nInstance().getFixedT(null, id); + transComponent = (props: TransProps) => ; + return; + } + + tFunc = getI18nInstance().t; + transComponent = (props: TransProps) => ; +} + // exported for testing export async function loadPluginResources(id: string, language: string, loaders?: ResourceLoader[]) { if (!loaders?.length) { @@ -41,8 +52,7 @@ export function initDefaultI18nInstance() { returnEmptyString: false, lng: DEFAULT_LANGUAGE, // this should be the locale of the phrases in our source JSX }); - tFunc = getI18nInstance().t; - transComponent = (props: TransProps) => ; + initTFuncAndTransComponent(); return initPromise; } @@ -63,8 +73,7 @@ export async function initPluginTranslations(id: string, loaders?: ResourceLoade initDefaultReactI18nInstance(); const language = getResolvedLanguage(); - tFunc = getI18nInstance().getFixedT(null, id); - transComponent = (props: TransProps) => ; + initTFuncAndTransComponent({ id }); await loadPluginResources(id, language, loaders); @@ -136,8 +145,7 @@ async function initTranslations({ await getI18nInstance().init(options); - tFunc = getI18nInstance().t; - transComponent = (props: TransProps) => ; + initTFuncAndTransComponent({ ns }); return { language: getResolvedLanguage(), diff --git a/packages/grafana-i18n/src/types.ts b/packages/grafana-i18n/src/types.ts index 52dc6d856bb..d952fb3e4a1 100644 --- a/packages/grafana-i18n/src/types.ts +++ b/packages/grafana-i18n/src/types.ts @@ -46,6 +46,10 @@ interface TransProps { * Values to interpolate into the translation */ values?: Record; + /** + * Class name for the Trans component + */ + className?: string; } /** diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index c19bae362d5..50e7ced3448 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -20,6 +20,7 @@ import { PluginLoadingStrategy, PluginDependencies, PluginExtensions, + TimeOption, } from '@grafana/data'; export interface AzureSettings { @@ -203,6 +204,7 @@ export class GrafanaBootConfig implements GrafanaConfig { reportingStaticContext?: Record; exploreDefaultTimeOffset = '1h'; exploreHideLogsDownload: boolean | undefined; + quickRanges?: TimeOption[]; /** * Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 9129f7c3d85..dbf274e51bc 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -41,6 +41,7 @@ export interface Options { showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; + syntaxHighlighting?: boolean; wrapLogMessage: boolean; } diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 5719f2da797..34ee46e0cff 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -1327,7 +1327,7 @@ export interface AdHocFilterWithLabels { keyLabel?: string; valueLabels?: string[]; forceEdit?: boolean; - origin?: FilterOrigin; + origin?: "dashboard"; // @deprecated condition?: string; } @@ -1339,10 +1339,7 @@ export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({ }); // Determine the origin of the adhoc variable filter -// Accepted values are `dashboard` (filter originated from dashboard), or `scope` (filter originated from scope). -export type FilterOrigin = "dashboard" | "scope"; - -export const defaultFilterOrigin = (): FilterOrigin => ("dashboard"); +export const FilterOrigin = "dashboard"; // Define the MetricFindValue type export interface MetricFindValue { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index f3b417ff746..c4b81c7ddc9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -75,10 +75,10 @@ "@leeoniya/ufuzzy": "1.0.18", "@monaco-editor/react": "4.7.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.25", - "@react-aria/focus": "3.20.3", - "@react-aria/overlays": "3.27.1", - "@react-aria/utils": "3.29.0", + "@react-aria/dialog": "3.5.27", + "@react-aria/focus": "3.20.5", + "@react-aria/overlays": "3.27.3", + "@react-aria/utils": "3.29.1", "@tanstack/react-virtual": "^3.5.1", "@types/jquery": "3.5.32", "@types/lodash": "4.17.15", diff --git a/packages/grafana-ui/src/components/Actions/ActionButton.tsx b/packages/grafana-ui/src/components/Actions/ActionButton.tsx index 00db7579dfd..f9ee2c39a25 100644 --- a/packages/grafana-ui/src/components/Actions/ActionButton.tsx +++ b/packages/grafana-ui/src/components/Actions/ActionButton.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { ActionModel, Field, ActionVariableInput } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Button, ButtonProps } from '../Button/Button'; import { ConfirmModal } from '../ConfirmModal/ConfirmModal'; diff --git a/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx index 53c3a283068..6524ebe4ec6 100644 --- a/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx +++ b/packages/grafana-ui/src/components/Actions/VariablesInputModal.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { ActionModel, ActionVariableInput } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Button } from '../Button/Button'; import { Field } from '../Forms/Field'; import { FieldSet } from '../Forms/FieldSet'; diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index 5d74780eac4..56b81d14351 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -4,10 +4,10 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; import { IconName } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import { Box } from '../Layout/Box/Box'; diff --git a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx index 637af988c80..f53670b73a6 100644 --- a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx +++ b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.tsx @@ -3,8 +3,9 @@ import { debounce } from 'lodash'; import { useCallback, useMemo, useRef } from 'react'; import * as React from 'react'; +import { Trans } from '@grafana/i18n'; + import { useStyles2 } from '../../themes/ThemeContext'; -import { Trans } from '../../utils/i18n'; import { Field, FieldProps } from '../Forms/Field'; import { InlineToast } from '../InlineToast/InlineToast'; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index dbbd20f2963..80194b56136 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -3,10 +3,10 @@ import { memo, cloneElement, FC, useMemo, useContext, ReactNode } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getFocusStyles } from '../../themes/mixins'; -import { t } from '../../utils/i18n'; import { CardContainer, CardContainerProps, getCardContainerStyles } from './CardContainer'; diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.tsx b/packages/grafana-ui/src/components/Carousel/Carousel.tsx index 8e3ff3d4d38..8e55e744a3e 100644 --- a/packages/grafana-ui/src/components/Carousel/Carousel.tsx +++ b/packages/grafana-ui/src/components/Carousel/Carousel.tsx @@ -5,9 +5,9 @@ import { OverlayContainer, useOverlay } from '@react-aria/overlays'; import { useState, useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Alert } from '../Alert/Alert'; import { clearButtonStyles } from '../Button/Button'; import { IconButton } from '../IconButton/IconButton'; diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx index 44762c2bbdb..9918a77d1b9 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -5,10 +5,10 @@ import { PureComponent } from 'react'; import * as React from 'react'; import { SelectableValue } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { withTheme2 } from '../../themes/ThemeContext'; import { Themeable2 } from '../../types/theme'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { IconButton } from '../IconButton/IconButton'; import { Input } from '../Input/Input'; diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index 94a49f35f04..dc8bf238ed8 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -3,9 +3,9 @@ import { useCallback, useRef, useState, useEffect } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Button, ButtonProps } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import { InlineToast } from '../InlineToast/InlineToast'; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx index 02b62d54573..c8c9f441978 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -4,11 +4,11 @@ import { Component } from 'react'; import * as React from 'react'; import { GrafanaTheme2, colorManipulator } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { withTheme2 } from '../../themes/ThemeContext'; import { stylesFactory } from '../../themes/stylesFactory'; import { Themeable2 } from '../../types/theme'; -import { t } from '../../utils/i18n'; import { Tab } from '../Tabs/Tab'; import { TabsBar } from '../Tabs/TabsBar'; import { PopoverContentProps } from '../Tooltip/types'; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx index 4b6d2a29d9f..5ddd263d447 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx @@ -5,9 +5,9 @@ import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; /** @internal */ export enum ColorSwatchVariant { diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx index d4cfbe381c5..5fd64e37772 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { ColorSwatch } from './ColorSwatch'; import NamedColorsGroup from './NamedColorsGroup'; diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx index 1757bf7c7ac..2c0350d9329 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -1,5 +1,6 @@ +import { t } from '@grafana/i18n'; + import { withTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { InlineField } from '../Forms/InlineField'; import { InlineSwitch } from '../Switch/Switch'; import { PopoverContentProps } from '../Tooltip/types'; diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 90ea3b2a69f..90865d8cb4f 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -3,8 +3,9 @@ import { useVirtualizer, type Range } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; import React, { useCallback, useId, useMemo } from 'react'; +import { t } from '@grafana/i18n'; + import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { AutoSizeInput } from '../Input/AutoSizeInput'; import { Input, Props as InputProps } from '../Input/Input'; diff --git a/packages/grafana-ui/src/components/Combobox/MessageRows.tsx b/packages/grafana-ui/src/components/Combobox/MessageRows.tsx index 6d01a560f22..a2d45162c01 100644 --- a/packages/grafana-ui/src/components/Combobox/MessageRows.tsx +++ b/packages/grafana-ui/src/components/Combobox/MessageRows.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; -import { Trans } from '../../utils/i18n'; +import { Trans } from '@grafana/i18n'; + import { Icon } from '../Icon/Icon'; import { Box } from '../Layout/Box/Box'; import { Stack } from '../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index a8dd52a5fc3..4545a828325 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -2,8 +2,9 @@ import { cx } from '@emotion/css'; import { useCombobox, useMultipleSelection } from 'downshift'; import { useCallback, useMemo, useState } from 'react'; +import { t } from '@grafana/i18n'; + import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { Box } from '../Layout/Box/Box'; import { Portal } from '../Portal/Portal'; diff --git a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx index ab157dde6b0..157438b65ad 100644 --- a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx +++ b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { forwardRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { IconButton } from '../IconButton/IconButton'; interface ValuePillProps { diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index d35b8157b7a..8b70f96b053 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -4,7 +4,7 @@ import { debounce } from 'lodash'; import { useState, useCallback, useMemo } from 'react'; -import { t } from '../../utils/i18n'; +import { t } from '@grafana/i18n'; import { fuzzyFind, itemToString } from './filter'; import { ComboboxOption } from './types'; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index ca47414b3b7..5ddfd473700 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -3,10 +3,10 @@ import { ReactElement, useEffect, useRef, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { ComponentSize } from '../../types/size'; -import { Trans } from '../../utils/i18n'; import { Button, ButtonVariant } from '../Button/Button'; export interface Props { diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx index e2849a9338a..16c93cd6a37 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx @@ -5,9 +5,9 @@ import { useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Button, ButtonVariant } from '../Button/Button'; import { Field } from '../Forms/Field'; import { Input } from '../Input/Input'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx index 48bd9a26730..5cbf57d48a4 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { memo, ChangeEvent } from 'react'; import { VariableSuggestion, GrafanaTheme2, DataLink } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Field } from '../Forms/Field'; import { Input } from '../Input/Input'; import { Switch } from '../Switch/Switch'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx index 4dce8241cad..990625d5bdb 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { DataFrame, DataLink, VariableSuggestion } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; -import { Trans } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { Modal } from '../../Modal/Modal'; import { DataLinkEditor } from '../DataLinkEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx index e36444feab7..778cce834c1 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase.tsx @@ -4,9 +4,9 @@ import { cloneDeep } from 'lodash'; import { useEffect, useState } from 'react'; import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { Modal } from '../../Modal/Modal'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx index b541f412f43..2f505e3bd1b 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx @@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css'; import { Draggable } from '@hello-pangea/dnd'; import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t } from '../../../utils/i18n'; import { Badge } from '../../Badge/Badge'; import { Icon } from '../../Icon/Icon'; import { IconButton } from '../../IconButton/IconButton'; diff --git a/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx index 7a6956e36e3..a59a374799c 100644 --- a/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx +++ b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { Field, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { Trans } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { DataLinkButton } from './DataLinkButton'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx index 7a36fe63e49..a71a5979f58 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx @@ -1,7 +1,7 @@ import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { InlineSwitch } from '../../components/Switch/Switch'; -import { t, Trans } from '../../utils/i18n'; import { InlineField } from '../Forms/InlineField'; export interface Props diff --git a/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx index f4519057cb4..b9957730a14 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; +import { t } from '@grafana/i18n'; + import { InlineField } from '../../components/Forms/InlineField'; -import { t } from '../../utils/i18n'; import { FormField } from '../FormField/FormField'; import { SecretFormField } from '../SecretFormField/SecretFormField'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx index 45cb22863ff..86f00684e1b 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, MouseEvent } from 'react'; -import { Trans } from '../../utils/i18n'; +import { Trans } from '@grafana/i18n'; + import { Button } from '../Button/Button'; import { InlineField } from '../Forms/InlineField'; import { InlineFieldRow } from '../Forms/InlineFieldRow'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx index 44d78d9c7a2..49e2abf2a2a 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx @@ -3,9 +3,9 @@ import { uniqueId } from 'lodash'; import { PureComponent } from 'react'; import { DataSourceSettings } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Button } from '../Button/Button'; import { FormField } from '../FormField/FormField'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index 993291bbfeb..e57f7747494 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -3,9 +3,9 @@ import { useState, useCallback, useId, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Alert } from '../Alert/Alert'; import { Button } from '../Button/Button'; import { Field } from '../Forms/Field'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx index 3d0a68738c6..9a7ddb5d705 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { InlineField } from '../Forms/InlineField'; import { Stack } from '../Layout/Stack/Stack'; import { InlineSwitch } from '../Switch/Switch'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx index 56e82d8a494..6004fff49e6 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx @@ -1,7 +1,7 @@ import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { InlineSwitch } from '../../components/Switch/Switch'; -import { t, Trans } from '../../utils/i18n'; import { InlineField } from '../Forms/InlineField'; export interface Props diff --git a/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx index fcd5a5ee00c..b5a1537edd8 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; import { KeyValue } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; -import { t, Trans } from '../../utils/i18n'; import { FormField } from '../FormField/FormField'; import { Icon } from '../Icon/Icon'; import { Tooltip } from '../Tooltip/Tooltip'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx index 6b4cb6c77b0..9e553274cfc 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx @@ -19,9 +19,9 @@ import { TimeZone, } from '@grafana/data'; import { Components } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { InlineField } from '../../Forms/InlineField'; import { Icon } from '../../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx index 58e7bb2d87a..7e49593d860 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx @@ -6,9 +6,9 @@ import { useOverlay } from '@react-aria/overlays'; import { FormEvent, useCallback, useRef, useState } from 'react'; import { RelativeTimeRange, GrafanaTheme2, TimeOption } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { Trans, t } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { Field } from '../../Forms/Field'; import { Icon } from '../../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index 1f0e8fb51e0..3888b76f6c4 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -16,9 +16,9 @@ import { getTimeZoneInfo, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { ButtonGroup } from '../Button/ButtonGroup'; import { getModalStyles } from '../Modal/getModalStyles'; import { getPortalContainer } from '../Portal/Portal'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx index 5279342e6d6..0615f729206 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx @@ -3,9 +3,9 @@ import { useCallback } from 'react'; import Calendar, { CalendarType } from 'react-calendar'; import { GrafanaTheme2, dateTimeParse, DateTime, TimeZone } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t } from '../../../utils/i18n'; import { Icon } from '../../Icon/Icon'; import { getWeekStart, WeekStart } from '../WeekStartPicker'; import { adjustDateForReactCalendar } from '../utils/adjustDateForReactCalendar'; @@ -92,6 +92,7 @@ export const getBodyStyles = (theme: GrafanaTheme2) => { // If a time range is part of only 1 day but does not encompass the whole day, // the class that react-calendar uses is '--hasActive' by itself (without being part of a '--range') const hasActiveSelector = `.react-calendar__tile--hasActive:not(.react-calendar__tile--range)`; + return { title: css({ color: theme.colors.text.primary, @@ -154,43 +155,51 @@ export const getBodyStyles = (theme: GrafanaTheme2) => { outline: 0, }, - [`${hasActiveSelector}, .react-calendar__tile--active`]: { - color: theme.colors.primary.contrastText, - fontWeight: theme.typography.fontWeightMedium, - background: theme.colors.primary.main, - border: '0px', + // The --hover modifier is active when the user is selecting a range and hovering over a tile - it shows the pending range. + // It is applied to all dates between the clicked date and the hovered date. + // The *clicked* date should have primary bg, while *pending* range dates should have hover bg. + '.react-calendar__tile--hover': { + backgroundColor: theme.colors.action.hover, + // eslint-disable-next-line @grafana/no-border-radius-literal + borderRadius: 0, }, - '.react-calendar__tile:hover:not(.react-calendar__tile--active):not(.react-calendar__tile--rangeEnd):not(.react-calendar__tile--rangeStart)': + '.react-calendar__tile--hoverStart': { + borderTopLeftRadius: theme.shape.radius.pill, + borderBottomLeftRadius: theme.shape.radius.pill, + }, + + '.react-calendar__tile--hoverEnd': { + borderTopRightRadius: theme.shape.radius.pill, + borderBottomRightRadius: theme.shape.radius.pill, + }, + + // Addiitonally, when hovering a date before clicking any, it should show the hover bg. + '.react-calendar__tile:hover:not(.react-calendar__tile--hover):not(.react-calendar__tile--active):not(.react-calendar__tile--hasActive)': { backgroundColor: theme.colors.action.hover, - }, - - '.react-calendar__tile--rangeEnd, .react-calendar__tile--rangeStart': { - padding: 0, - border: '0px', - color: theme.colors.primary.contrastText, - fontWeight: theme.typography.fontWeightMedium, - background: theme.colors.primary.main, - - abbr: { - backgroundColor: theme.colors.primary.main, borderRadius: theme.shape.radius.pill, - display: 'block', - paddingTop: '2px', - height: '26px', }, + + // When the user is selecting a range (they've clicked one date, tiles have --hover), both --rangeStart and --rangeEnd are on the tile. + // The --hover classes above handle the rounding of the tiles so they're contigious with the range + [`${hasActiveSelector}, .react-calendar__tile--rangeStart:not(.react-calendar__tile--hover)`]: { + borderTopLeftRadius: theme.shape.radius.pill, + borderBottomLeftRadius: theme.shape.radius.pill, }, - [`${hasActiveSelector}, .react-calendar__tile--rangeStart`]: { - borderTopLeftRadius: '20px', - borderBottomLeftRadius: '20px', + [`${hasActiveSelector}, .react-calendar__tile--rangeEnd:not(.react-calendar__tile--hover)`]: { + borderTopRightRadius: theme.shape.radius.pill, + borderBottomRightRadius: theme.shape.radius.pill, }, - [`${hasActiveSelector}, .react-calendar__tile--rangeEnd`]: { - borderTopRightRadius: '20px', - borderBottomRightRadius: '20px', - }, + [`${hasActiveSelector}, .react-calendar__tile--active, .react-calendar__tile--rangeEnd, .react-calendar__tile--rangeStart`]: + { + color: theme.colors.primary.contrastText, + fontWeight: theme.typography.fontWeightMedium, + background: theme.colors.primary.main, + border: '0px', + }, }), }; }; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx index 3cc79b765ee..0e9964b700e 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx @@ -1,4 +1,5 @@ -import { Trans } from '../../../utils/i18n'; +import { Trans } from '@grafana/i18n'; + import { Button } from '../../Button/Button'; import { Stack } from '../../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarHeader.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarHeader.tsx index 8c02a21cee4..8253770e4b5 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarHeader.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarHeader.tsx @@ -1,6 +1,6 @@ import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; -import { Trans, t } from '../../../utils/i18n'; import { IconButton } from '../../IconButton/IconButton'; import { Stack } from '../../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index b446ce24b8d..0bf13180e1a 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -3,10 +3,10 @@ import { memo, useMemo, useState } from 'react'; import { GrafanaTheme2, isDateTime, rangeUtil, RawTimeRange, TimeOption, TimeRange, TimeZone } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; import { getFocusStyles } from '../../../themes/mixins'; -import { t, Trans } from '../../../utils/i18n'; import { FilterInput } from '../../FilterInput/FilterInput'; import { Icon } from '../../Icon/Icon'; import { WeekStart } from '../WeekStartPicker'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx index d00a679a9e9..69fe98c09d5 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx @@ -5,9 +5,9 @@ import * as React from 'react'; import { getTimeZoneInfo, GrafanaTheme2, TimeZone } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { Combobox } from '../../Combobox/Combobox'; import { Field } from '../../Forms/Field'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index bef32e7005e..87a751ca1a1 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -14,9 +14,9 @@ import { TimeZone, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { Field } from '../../Forms/Field'; import { Icon } from '../../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx index d7877a958cc..acf2680adc0 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useRef, ReactNode } from 'react'; import { GrafanaTheme2, TimeOption } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../themes/ThemeContext'; -import { t } from '../../../utils/i18n'; import { TimePickerTitle } from './TimePickerTitle'; import { TimeRangeOption } from './TimeRangeOption'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx index 467aa123905..7325ec93de9 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx @@ -1,4 +1,5 @@ -import { t } from '../../utils/i18n'; +import { t } from '@grafana/i18n'; + import { ToolbarButton } from '../ToolbarButton/ToolbarButton'; import { Tooltip } from '../Tooltip/Tooltip'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx index 5af07dfe798..f41279cb983 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx @@ -10,8 +10,8 @@ import { TimeZone, InternalTimeZones, } from '@grafana/data'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { Select } from '../Select/Select'; import { TimeZoneGroup } from './TimeZonePicker/TimeZoneGroup'; diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 399cecca5be..5af570c7fe9 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -8,9 +8,9 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { getDragStyles } from '../DragHandle/DragHandle'; import { IconButton } from '../IconButton/IconButton'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; diff --git a/packages/grafana-ui/src/components/FeatureBadge/FeatureBadge.tsx b/packages/grafana-ui/src/components/FeatureBadge/FeatureBadge.tsx index 8265ada629b..6a67913e453 100644 --- a/packages/grafana-ui/src/components/FeatureBadge/FeatureBadge.tsx +++ b/packages/grafana-ui/src/components/FeatureBadge/FeatureBadge.tsx @@ -1,6 +1,6 @@ import { FeatureState } from '@grafana/data'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { Badge, BadgeProps } from '../Badge/Badge'; export interface FeatureBadgeProps { diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx index 479d91d99a8..1d27de173a1 100644 --- a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx @@ -4,9 +4,9 @@ import { ReactNode, useCallback, useState } from 'react'; import { Accept, DropEvent, DropzoneOptions, FileError, FileRejection, useDropzone, ErrorCode } from 'react-dropzone'; import { formattedValueToString, getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Alert } from '../Alert/Alert'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx b/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx index 8d08665ddf1..b13066621c6 100644 --- a/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx +++ b/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/css'; import { formattedValueToString, getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { trimFileName } from '../../utils/file'; -import { t, Trans } from '../../utils/i18n'; import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import { IconButton } from '../IconButton/IconButton'; diff --git a/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx b/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx index 2cbdd552ef5..528d4a7e193 100644 --- a/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx +++ b/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx @@ -5,12 +5,12 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getFocusStyles } from '../../themes/mixins'; import { ComponentSize } from '../../types/size'; import { trimFileName } from '../../utils/file'; -import { t } from '../../utils/i18n'; import { getButtonStyles } from '../Button/Button'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx index 41bb49be85d..93a238e7d97 100644 --- a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx +++ b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx @@ -1,8 +1,8 @@ import { forwardRef, useRef, HTMLProps } from 'react'; import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; -import { Trans } from '../../utils/i18n'; import { useCombinedRefs } from '../../utils/useCombinedRefs'; import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx index 59451b5c4a8..346d18b38fb 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; import { CellProps, HeaderProps } from 'react-table'; -import { t } from '../../../utils/i18n'; +import { t } from '@grafana/i18n'; + import { IconButton } from '../../IconButton/IconButton'; const expanderContainerStyles = css({ diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx index 7bc1f6e61ab..be53d365b5c 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx @@ -2,8 +2,8 @@ import { memo, useCallback } from 'react'; import * as React from 'react'; import { FieldMatcherID, fieldMatchers } from '@grafana/data'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { Input } from '../Input/Input'; import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types'; diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx index cd7ffcb2257..70bddcbe257 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldValueMatcher.tsx @@ -11,10 +11,10 @@ import { SelectableValue, GrafanaTheme2, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { ComparisonOperation } from '@grafana/schema'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Input } from '../Input/Input'; import { Select } from '../Select/Select'; diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 0f39c690c37..1c2fdd6c3e8 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -3,11 +3,11 @@ import { ReactElement, useCallback, useState, useRef, useImperativeHandle, CSSPr import * as React from 'react'; import { GrafanaTheme2, LinkTarget } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getFocusStyles } from '../../themes/mixins'; import { IconName } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index ea35cc7948f..5ce59a89fca 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -5,9 +5,10 @@ import { OverlayContainer, useOverlay } from '@react-aria/overlays'; import { PropsWithChildren, useRef } from 'react'; import * as React from 'react'; +import { t } from '@grafana/i18n'; + import { useStyles2 } from '../../themes/ThemeContext'; import { IconName } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { IconButton } from '../IconButton/IconButton'; import { Stack } from '../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx index a4875ca978a..bdeca4ee5c2 100644 --- a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx +++ b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { useAsyncDependency } from '../../utils/useAsyncDependency'; import { ErrorWithStack } from '../ErrorBoundary/ErrorWithStack'; import { LoadingPlaceholder } from '../LoadingPlaceholder/LoadingPlaceholder'; diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index 18199350164..c010ded3766 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -3,11 +3,11 @@ import { memo, Children, ReactNode } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getFocusStyles } from '../../themes/mixins'; import { IconName } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { IconButton } from '../IconButton/IconButton'; import { Link } from '../Link/Link'; diff --git a/packages/grafana-ui/src/components/Pagination/Pagination.tsx b/packages/grafana-ui/src/components/Pagination/Pagination.tsx index 394f267b894..70c447e059c 100644 --- a/packages/grafana-ui/src/components/Pagination/Pagination.tsx +++ b/packages/grafana-ui/src/components/Pagination/Pagination.tsx @@ -1,8 +1,9 @@ import { css, cx } from '@emotion/css'; import { useMemo } from 'react'; +import { t } from '@grafana/i18n'; + import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Button, ButtonVariant } from '../Button/Button'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx index 19fc83fa6d6..bb83665428d 100644 --- a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx @@ -2,9 +2,9 @@ import { css, cx, keyframes } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { Tooltip } from '../Tooltip/Tooltip'; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 1e98084081b..79ed1581344 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -5,11 +5,11 @@ import { useMeasure, useToggle } from 'react-use'; import { GrafanaTheme2, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; import { getFocusStyles } from '../../themes/mixins'; import { DelayRender } from '../../utils/DelayRender'; -import { t } from '../../utils/i18n'; import { usePointerDistance } from '../../utils/usePointerDistance'; import { useElementSelection } from '../ElementSelectionContext/ElementSelectionContext'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx index e56d8c6302f..5be2a7913f3 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx @@ -2,8 +2,8 @@ import { cx } from '@emotion/css'; import { ReactElement, useCallback } from 'react'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { Dropdown } from '../Dropdown/Dropdown'; import { ToolbarButton } from '../ToolbarButton/ToolbarButton'; import { TooltipPlacement } from '../Tooltip/types'; diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 36c02708329..22d300bfacc 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -4,8 +4,8 @@ import { PureComponent } from 'react'; import { SelectableValue, parseDuration } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { ButtonGroup } from '../Button/ButtonGroup'; import { ButtonSelect } from '../Dropdown/ButtonSelect'; import { ToolbarButton, ToolbarButtonVariant } from '../ToolbarButton/ToolbarButton'; diff --git a/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx b/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx index 8989060a759..fb52282f923 100644 --- a/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx +++ b/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx @@ -3,7 +3,8 @@ import { omit } from 'lodash'; import { InputHTMLAttributes } from 'react'; import * as React from 'react'; -import { Trans } from '../../utils/i18n'; +import { Trans } from '@grafana/i18n'; + import { Button } from '../Button/Button'; import { FormField } from '../FormField/FormField'; import { Field } from '../Forms/Field'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx index a8fe446c181..804b1bbe92f 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentAsync.tsx @@ -6,9 +6,9 @@ import { useAsyncFn } from 'react-use'; import { type AsyncState } from 'react-use/lib/useAsync'; import { SelectableValue } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { InlineLabel } from '../Forms/InlineLabel'; import { SegmentSelect } from './SegmentSelect'; diff --git a/packages/grafana-ui/src/components/Select/MultiValue.tsx b/packages/grafana-ui/src/components/Select/MultiValue.tsx index fb8c3104660..987d34a1290 100644 --- a/packages/grafana-ui/src/components/Select/MultiValue.tsx +++ b/packages/grafana-ui/src/components/Select/MultiValue.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; +import { t } from '@grafana/i18n'; + import { useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { IconButton, Props as IconButtonProps } from '../IconButton/IconButton'; import { getSelectStyles } from './getSelectStyles'; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index c573eda0ae8..0eb6a1fefeb 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -12,9 +12,9 @@ import { default as AsyncCreatable } from 'react-select/async-creatable'; import Creatable from 'react-select/creatable'; import { SelectableValue, toOption } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { CustomInput } from './CustomInput'; diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index 713e2865f53..bb12cf4dcfb 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -6,9 +6,9 @@ import { FixedSizeList as List } from 'react-window'; import { SelectableValue, toIconName } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t, Trans } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { clearButtonStyles } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.tsx index ed50dbf7cde..78905e29eec 100644 --- a/packages/grafana-ui/src/components/Spinner/Spinner.tsx +++ b/packages/grafana-ui/src/components/Spinner/Spinner.tsx @@ -3,10 +3,10 @@ import * as React from 'react'; import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { IconSize, isIconSize } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { spin } from '../../utils/keyframes'; import { Icon } from '../Icon/Icon'; import { getIconRoot, getIconSubDir } from '../Icon/utils'; diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx index 6efb5c68243..e2fe8a658ba 100644 --- a/packages/grafana-ui/src/components/Table/CellActions.tsx +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -1,8 +1,9 @@ import { useCallback } from 'react'; import * as React from 'react'; +import { t } from '@grafana/i18n'; + import { IconSize } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { IconButton } from '../IconButton/IconButton'; import { Stack } from '../Layout/Stack/Stack'; import { TooltipPlacement } from '../Tooltip/types'; diff --git a/packages/grafana-ui/src/components/Table/TableCellInspector.tsx b/packages/grafana-ui/src/components/Table/TableCellInspector.tsx index 8de4a8178f5..ed7f9111773 100644 --- a/packages/grafana-ui/src/components/Table/TableCellInspector.tsx +++ b/packages/grafana-ui/src/components/Table/TableCellInspector.tsx @@ -1,7 +1,8 @@ import { isString } from 'lodash'; import { useState } from 'react'; -import { t, Trans } from '../../utils/i18n'; +import { t, Trans } from '@grafana/i18n'; + import { ClipboardButton } from '../ClipboardButton/ClipboardButton'; import { Drawer } from '../Drawer/Drawer'; import { Stack } from '../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx index 85ac8b4cdb7..95e29ce17f3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { t } from '../../../../utils/i18n'; import { Icon } from '../../../Icon/Icon'; import { RowExpanderNGProps } from '../types'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx index aacf5bb6071..a3a57a5564c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx @@ -4,10 +4,10 @@ import { Geometry } from 'ol/geom'; import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { TableAutoCellOptions, TableCellDisplayMode } from '@grafana/schema'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { t } from '../../../../utils/i18n'; import { IconButton } from '../../../IconButton/IconButton'; // import { GeoCell } from '../../Cells/GeoCell'; import { TableCellInspectorMode } from '../../TableCellInspector'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx index 0089b125caf..783c25f0691 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx @@ -4,9 +4,9 @@ import * as React from 'react'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../../themes/ThemeContext'; -import { Trans } from '../../../../utils/i18n'; import { Checkbox } from '../../../Forms/Checkbox'; import { Label } from '../../../Forms/Label'; import { Stack } from '../../../Layout/Stack/Stack'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx index 93322747d46..d11d66cf2e0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import React, { useCallback, useMemo, useState } from 'react'; import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../../themes/ThemeContext'; -import { t, Trans } from '../../../../utils/i18n'; import { Button } from '../../../Button/Button'; import { ClickOutsideWrapper } from '../../../ClickOutsideWrapper/ClickOutsideWrapper'; import { ButtonSelect } from '../../../Dropdown/ButtonSelect'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 91bfc448e03..22ed8d6105c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -16,10 +16,10 @@ import { GrafanaTheme2, ReducerID, } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { ContextMenu } from '../../ContextMenu/ContextMenu'; import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index 8e6d662f19f..dccddd89bd8 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -12,6 +12,7 @@ import { LinkModel, ValueLinkConfig, } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, @@ -19,7 +20,6 @@ import { TableCellHeight, } from '@grafana/schema'; -import { Trans } from '../../../utils/i18n'; import { PanelContext } from '../../PanelChrome'; import { mapFrameToDataGrid, myRowRenderer } from './TableNG'; diff --git a/packages/grafana-ui/src/components/Table/TableRT/FilterList.tsx b/packages/grafana-ui/src/components/Table/TableRT/FilterList.tsx index 47cba0739ed..273f97d684f 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/FilterList.tsx +++ b/packages/grafana-ui/src/components/Table/TableRT/FilterList.tsx @@ -4,9 +4,9 @@ import * as React from 'react'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { ButtonSelect } from '../../Dropdown/ButtonSelect'; import { FilterInput } from '../../FilterInput/FilterInput'; import { Checkbox } from '../../Forms/Checkbox'; diff --git a/packages/grafana-ui/src/components/Table/TableRT/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/TableRT/FilterPopup.tsx index cebbf3eae9a..f860f0ce830 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/TableRT/FilterPopup.tsx @@ -3,9 +3,9 @@ import { useCallback, useMemo, useState } from 'react'; import * as React from 'react'; import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; -import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button/Button'; import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper'; import { Label } from '../../Forms/Label'; diff --git a/packages/grafana-ui/src/components/Table/TableRT/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableRT/RowExpander.tsx index 78cef901c25..26d1c681589 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/TableRT/RowExpander.tsx @@ -1,4 +1,5 @@ -import { t } from '../../../utils/i18n'; +import { t } from '@grafana/i18n'; + import { Icon } from '../../Icon/Icon'; import { GrafanaTableRow } from '../types'; diff --git a/packages/grafana-ui/src/components/Table/TableRT/Table.tsx b/packages/grafana-ui/src/components/Table/TableRT/Table.tsx index d90df5c7f5a..4df81505431 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/Table.tsx +++ b/packages/grafana-ui/src/components/Table/TableRT/Table.tsx @@ -12,10 +12,10 @@ import { VariableSizeList } from 'react-window'; import { FieldType, ReducerID, getRowUniqueId, getFieldMatcher } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { Trans } from '@grafana/i18n'; import { TableCellHeight } from '@grafana/schema'; import { useTheme2 } from '../../../themes/ThemeContext'; -import { Trans } from '../../../utils/i18n'; import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar'; import { Pagination } from '../../Pagination/Pagination'; import { TableCellInspector } from '../TableCellInspector'; diff --git a/packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.tsx b/packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.tsx index e4ef3bfaff3..f4a3e11e755 100644 --- a/packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.tsx +++ b/packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.tsx @@ -4,11 +4,11 @@ import { PureComponent } from 'react'; import * as React from 'react'; import { DataFrame, CSVConfig, readCSV, GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { withTheme2 } from '../../themes/ThemeContext'; import { stylesFactory } from '../../themes/stylesFactory'; import { Themeable2 } from '../../types/theme'; -import { t, Trans } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { TextArea } from '../TextArea/TextArea'; diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index ed15b032479..68e70c02c36 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -2,10 +2,10 @@ import { css, cx } from '@emotion/css'; import { forwardRef, memo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; import { IconName } from '../../types/icon'; -import { t } from '../../utils/i18n'; import { SkeletonComponent, attachSkeleton } from '../../utils/skeleton'; import { OnTagClick, Tag } from './Tag'; diff --git a/packages/grafana-ui/src/components/TagsInput/TagItem.tsx b/packages/grafana-ui/src/components/TagsInput/TagItem.tsx index 4ed082f2c9a..a5c47722e6f 100644 --- a/packages/grafana-ui/src/components/TagsInput/TagItem.tsx +++ b/packages/grafana-ui/src/components/TagsInput/TagItem.tsx @@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css'; import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { getTagColorsFromName } from '../../utils/tags'; import { IconButton } from '../IconButton/IconButton'; diff --git a/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx b/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx index 1ecd72ed50c..808e5245ae0 100644 --- a/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx +++ b/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx @@ -3,9 +3,9 @@ import { useCallback, useState, forwardRef } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; -import { Trans } from '../../utils/i18n'; import { Button } from '../Button/Button'; import { Input } from '../Input/Input'; diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index b1d42be8bee..035fe22b6b6 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -16,9 +16,9 @@ import { Placement } from '@popperjs/core'; import { memo, cloneElement, isValidElement, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils'; import { IconButton } from '../IconButton/IconButton'; diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx index 924c5e723f4..000713c57c4 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx @@ -5,9 +5,9 @@ import { useOverlay } from '@react-aria/overlays'; import { Children, forwardRef, HTMLAttributes, useState, useRef, useLayoutEffect, createRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { getPortalContainer } from '../Portal/Portal'; import { ToolbarButton } from './ToolbarButton'; diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx index 4b25e07f172..2709bfa067c 100644 --- a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx @@ -1,8 +1,8 @@ import { PureComponent } from 'react'; import { getValueFormats, SelectableValue } from '@grafana/data'; +import { t } from '@grafana/i18n'; -import { t } from '../../utils/i18n'; import { Cascader, CascaderOption } from '../Cascader/Cascader'; export interface UnitPickerProps { diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx index 8d9cf905db2..3d8dd97f962 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx @@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css'; import { useMemo, PropsWithChildren } from 'react'; import { dateTime, DateTimeInput, GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; import { useTheme2 } from '../../themes/ThemeContext'; -import { t, Trans } from '../../utils/i18n'; import { Tooltip } from '../Tooltip/Tooltip'; import { UserView } from './types'; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index 16ca77cd69c..44518ce25c7 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { UserIcon } from './UserIcon'; import { UserView } from './types'; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 52b2aa5d60d..335cf4309e9 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -3,10 +3,10 @@ import { useCallback } from 'react'; import * as React from 'react'; import { formattedValueToString, GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { hoverColor } from '../../themes/mixins'; -import { Trans } from '../../utils/i18n'; import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; import { VizLegendItem } from './types'; diff --git a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx index 30013bbd58b..5d191cf81ae 100644 --- a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx @@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; import { GrafanaTheme2, GraphSeriesValue } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { t } from '../../utils/i18n'; import { SeriesIcon } from '../VizLegend/SeriesIcon'; /** diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index 267c09bed9c..db5d1e88c37 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { Trans } from '../../utils/i18n'; import { ActionButton } from '../Actions/ActionButton'; import { Button } from '../Button/Button'; import { DataLinkButton } from '../DataLinks/DataLinkButton'; diff --git a/packages/grafana-ui/src/options/builder/axis.tsx b/packages/grafana-ui/src/options/builder/axis.tsx index f539a55a0fc..725c1d24689 100644 --- a/packages/grafana-ui/src/options/builder/axis.tsx +++ b/packages/grafana-ui/src/options/builder/axis.tsx @@ -5,6 +5,7 @@ import { SelectableValue, StandardEditorProps, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { AxisColorMode, AxisConfig, AxisPlacement, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; import { Field } from '../../components/Forms/Field'; @@ -13,7 +14,6 @@ import { Input } from '../../components/Input/Input'; import { Stack } from '../../components/Layout/Stack/Stack'; import { Select } from '../../components/Select/Select'; import { graphFieldOptions } from '../../components/uPlot/config'; -import { t } from '../../utils/i18n'; const category = ['Axis']; diff --git a/packages/grafana-ui/src/options/builder/stacking.tsx b/packages/grafana-ui/src/options/builder/stacking.tsx index 2fc840f0284..4695571a236 100644 --- a/packages/grafana-ui/src/options/builder/stacking.tsx +++ b/packages/grafana-ui/src/options/builder/stacking.tsx @@ -5,6 +5,7 @@ import { identityOverrideProcessor, SelectableValue, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { GraphFieldConfig, StackingConfig, StackingMode } from '@grafana/schema'; import { RadioButtonGroup } from '../../components/Forms/RadioButtonGroup/RadioButtonGroup'; @@ -12,7 +13,6 @@ import { IconButton } from '../../components/IconButton/IconButton'; import { Input } from '../../components/Input/Input'; import { Stack } from '../../components/Layout/Stack/Stack'; import { graphFieldOptions } from '../../components/uPlot/config'; -import { t } from '../../utils/i18n'; export const StackingEditor = ({ value, diff --git a/packages/grafana-ui/src/utils/i18n.tsx b/packages/grafana-ui/src/utils/i18n.tsx deleted file mode 100644 index 83fcce10b8b..00000000000 --- a/packages/grafana-ui/src/utils/i18n.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import i18next from 'i18next'; -import { ReactElement } from 'react'; -import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports - -// We want to translate grafana-ui without introducing any breaking changes for consumers -// who use grafana-ui outside of grafana (such as grafana.com self serve). The other struggle -// is that grafana-ui does not require a top-level provider component, so we don't get the -// chance to do the mandatory i18next setup that and t() requires -// -// We wrap and t() and do a simple check if it hasn't already been set up -// (Grafana will init i18next in app.ts), and just set it up with a minimal config -// to use the default phrases in the source jsx. - -// Creates a default, english i18next instance when running outside of grafana. -// we don't support changing the locale of grafana ui when outside of Grafana -function initI18n() { - // resources is undefined by default and set either by grafana app.ts or here - if (typeof i18next.options.resources !== 'object') { - i18next.use(initReactI18next).init({ - resources: {}, - returnEmptyString: false, - lng: 'en-US', // this should be the locale of the phrases in our source JSX - }); - } -} - -type I18NextTransType = typeof I18NextTrans; -type I18NextTransProps = Parameters[0]; - -interface TransProps extends I18NextTransProps { - i18nKey: string; - className?: string; -} - -export const Trans = (props: TransProps): ReactElement => { - initI18n(); - return ; -}; - -// Reassign t() so i18next-parser doesn't warn on dynamic key, and we can have 'failOnWarnings' enabled -const tFunc = i18next.t; - -export const t = (id: string, defaultMessage: string, values?: Record) => { - initI18n(); - - return tFunc(id, defaultMessage, values); -}; diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 93d68def008..31ee8d66810 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -61,34 +61,28 @@ func (hs *HTTPServer) GetAnnotations(c *contextmodel.ReqContext) response.Respon if err != nil { return response.Error(http.StatusBadRequest, "Invalid dashboard UID in annotation request", err) } else { - query.DashboardID = dqResult.ID + query.DashboardID = dqResult.ID // nolint:staticcheck } } + if query.DashboardID != 0 && query.DashboardUID == "" { // nolint:staticcheck + dq := dashboards.GetDashboardQuery{ID: query.DashboardID, OrgID: c.GetOrgID()} // nolint:staticcheck + dqResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &dq) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid dashboard ID in annotation request", err) + } + query.DashboardUID = dqResult.UID + } + items, err := hs.annotationsRepo.Find(c.Req.Context(), query) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to get annotations", err) } - // since there are several annotations per dashboard, we can cache dashboard uid - dashboardCache := make(map[int64]*string) for _, item := range items { if item.Email != "" { item.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, item.Email) } - - if item.DashboardID != 0 { - if val, ok := dashboardCache[item.DashboardID]; ok { - item.DashboardUID = val - } else { - query := dashboards.GetDashboardQuery{ID: item.DashboardID, OrgID: c.GetOrgID()} - queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) - if err == nil && queryResult != nil { - item.DashboardUID = &queryResult.UID - dashboardCache[item.DashboardID] = &queryResult.UID - } - } - } } return response.JSON(http.StatusOK, items) @@ -131,7 +125,17 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon } } - if canSave, err := hs.canCreateAnnotation(c, cmd.DashboardId); err != nil || !canSave { + // get dashboard uid if not provided + if cmd.DashboardId != 0 && cmd.DashboardUID == "" { + query := dashboards.GetDashboardQuery{OrgID: c.GetOrgID(), ID: cmd.DashboardId} + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid dashboard ID in annotation request", err) + } + cmd.DashboardUID = queryResult.UID + } + + if canSave, err := hs.canCreateAnnotation(c, cmd.DashboardUID); err != nil || !canSave { if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { return dashboardGuardianResponse(err) } else if err != nil { @@ -148,15 +152,16 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon userID, _ := identity.UserIdentifier(c.GetID()) item := annotations.Item{ - OrgID: c.GetOrgID(), - UserID: userID, - DashboardID: cmd.DashboardId, - PanelID: cmd.PanelId, - Epoch: cmd.Time, - EpochEnd: cmd.TimeEnd, - Text: cmd.Text, - Data: cmd.Data, - Tags: cmd.Tags, + OrgID: c.GetOrgID(), + UserID: userID, + DashboardID: cmd.DashboardId, + DashboardUID: cmd.DashboardUID, + PanelID: cmd.PanelId, + Epoch: cmd.Time, + EpochEnd: cmd.TimeEnd, + Text: cmd.Text, + Data: cmd.Data, + Tags: cmd.Tags, } if err := hs.annotationsRepo.Save(c.Req.Context(), &item); err != nil { @@ -402,6 +407,15 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response } } + if cmd.DashboardId != 0 && cmd.DashboardUID == "" { + query := dashboards.GetDashboardQuery{OrgID: c.GetOrgID(), ID: cmd.DashboardId} + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid dashboard ID in annotation request", err) + } + cmd.DashboardUID = queryResult.UID + } + if (cmd.DashboardId != 0 && cmd.PanelId == 0) || (cmd.PanelId != 0 && cmd.DashboardId == 0) { err := &AnnotationError{message: "DashboardId and PanelId are both required for mass delete"} return response.Error(http.StatusBadRequest, "bad request data", err) @@ -411,28 +425,29 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response // validations only for RBAC. A user can mass delete all annotations in a (dashboard + panel) or a specific annotation // if has access to that dashboard. - var dashboardId int64 + var dashboardUID string if cmd.AnnotationId != 0 { annotation, respErr := findAnnotationByID(c.Req.Context(), hs.annotationsRepo, cmd.AnnotationId, c.SignedInUser) if respErr != nil { return respErr } - dashboardId = annotation.DashboardID + dashboardUID = *annotation.DashboardUID deleteParams = &annotations.DeleteParams{ OrgID: c.GetOrgID(), ID: cmd.AnnotationId, } } else { - dashboardId = cmd.DashboardId + dashboardUID = cmd.DashboardUID deleteParams = &annotations.DeleteParams{ - OrgID: c.GetOrgID(), - DashboardID: cmd.DashboardId, - PanelID: cmd.PanelId, + OrgID: c.GetOrgID(), + DashboardID: cmd.DashboardId, + DashboardUID: cmd.DashboardUID, + PanelID: cmd.PanelId, } } - canSave, err := hs.canMassDeleteAnnotations(c, dashboardId) + canSave, err := hs.canMassDeleteAnnotations(c, dashboardUID) if err != nil || !canSave { if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { return dashboardGuardianResponse(err) @@ -519,14 +534,14 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response. func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, annotation *annotations.ItemDTO) (bool, error) { if annotation.GetType() == annotations.Dashboard { - return canEditDashboard(c, ac, annotation.DashboardID) + return canEditDashboard(c, ac, *annotation.DashboardUID) } else { return true, nil } } -func canEditDashboard(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, dashboardID int64) (bool, error) { - evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10))) +func canEditDashboard(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, dashboardUID string) (bool, error) { + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboardUID)) return ac.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } @@ -630,11 +645,11 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature } } - if annotation.DashboardID == 0 { + if annotation.DashboardUID == nil || *annotation.DashboardUID == "" { return []string{accesscontrol.ScopeAnnotationsTypeOrganization}, nil } else { return identity.WithServiceIdentityFn(ctx, orgID, func(ctx context.Context) ([]string, error) { - dashboard, err := dashSvc.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: annotation.DashboardID, OrgID: orgID}) + dashboard, err := dashSvc.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: *annotation.DashboardUID, OrgID: orgID}) if err != nil { return nil, err } @@ -656,10 +671,10 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature }) } -func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardId int64) (bool, error) { +func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardUID string) (bool, error) { if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { - if dashboardId != 0 { - evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardId, 10))) + if dashboardUID != "" { + evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboardUID)) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } else { // organization annotations evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeOrganization) @@ -667,31 +682,31 @@ func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardI } } - if dashboardId != 0 { + if dashboardUID != "" { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeDashboard) if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { return canSave, err } - return canEditDashboard(c, hs.AccessControl, dashboardId) + return canEditDashboard(c, hs.AccessControl, dashboardUID) } else { // organization annotations evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeOrganization) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } } -func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashboardID int64) (bool, error) { +func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashboardUID string) (bool, error) { if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { - if dashboardID == 0 { + if dashboardUID == "" { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, accesscontrol.ScopeAnnotationsTypeOrganization) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } else { - evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10))) + evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboardUID)) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } } - if dashboardID == 0 { + if dashboardUID == "" { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, accesscontrol.ScopeAnnotationsTypeOrganization) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } else { @@ -701,7 +716,7 @@ func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashb return false, err } - canSave, err = canEditDashboard(c, hs.AccessControl, dashboardID) + canSave, err = canEditDashboard(c, hs.AccessControl, dashboardUID) if err != nil || !canSave { return false, err } diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 6eaf4317be6..cffc023b909 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -399,8 +399,8 @@ func TestAPI_Annotations(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() repo := annotationstest.NewFakeAnnotationsRepo() - _ = repo.Save(context.Background(), &annotations.Item{ID: 1, DashboardID: 0}) - _ = repo.Save(context.Background(), &annotations.Item{ID: 2, DashboardID: 1}) + _ = repo.Save(context.Background(), &annotations.Item{ID: 1, DashboardID: 0, DashboardUID: ""}) + _ = repo.Save(context.Background(), &annotations.Item{ID: 2, DashboardID: 1, DashboardUID: "dashuid1"}) hs.annotationsRepo = repo hs.Features = featuremgmt.WithFeatures(tt.featureFlags...) dashService := &dashboards.FakeDashboardService{} @@ -413,7 +413,7 @@ func TestAPI_Annotations(t *testing.T) { hs.folderService = folderService hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver(hs.annotationsRepo, hs.Features, dashService, folderService)) - hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(dashService, folderService)) + hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashService, folderService)) }) var body io.Reader if tt.body != "" { @@ -436,11 +436,11 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) { dashSvc := &dashboards.FakeDashboardService{} rootDash := &dashboards.Dashboard{ID: 1, OrgID: 1, UID: rootDashUID} folderDash := &dashboards.Dashboard{ID: 2, OrgID: 1, UID: folderDashUID, FolderUID: folderUID} - dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{ID: rootDash.ID, OrgID: 1}).Return(rootDash, nil) - dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{ID: folderDash.ID, OrgID: 1}).Return(folderDash, nil) + dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{UID: rootDash.UID, OrgID: 1}).Return(rootDash, nil) + dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{UID: folderDash.UID, OrgID: 1}).Return(folderDash, nil) - rootDashboardAnnotation := annotations.Item{ID: 1, DashboardID: rootDash.ID} - folderDashboardAnnotation := annotations.Item{ID: 3, DashboardID: folderDash.ID} + rootDashboardAnnotation := annotations.Item{ID: 1, DashboardID: rootDash.ID, DashboardUID: rootDash.UID} + folderDashboardAnnotation := annotations.Item{ID: 3, DashboardID: folderDash.ID, DashboardUID: folderDash.UID} organizationAnnotation := annotations.Item{ID: 2} fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo() diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 3ad8335151d..bff3ffadc7a 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -580,16 +580,15 @@ func (hs *HTTPServer) GetHomeDashboard(c *contextmodel.ReqContext) response.Resp return response.Error(http.StatusInternalServerError, "Failed to get preferences", err) } - if preference.HomeDashboardID == 0 && len(homePage) > 0 { + if preference.HomeDashboardUID == "" && len(homePage) > 0 { homePageRedirect := dtos.DashboardRedirect{RedirectUri: homePage} return response.JSON(http.StatusOK, &homePageRedirect) } - if preference.HomeDashboardID != 0 { - slugQuery := dashboards.GetDashboardRefByIDQuery{ID: preference.HomeDashboardID} - slugQueryResult, err := hs.DashboardService.GetDashboardUIDByID(c.Req.Context(), &slugQuery) + if preference.HomeDashboardUID != "" { + slugQueryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &dashboards.GetDashboardQuery{UID: preference.HomeDashboardUID, OrgID: c.GetOrgID()}) if err == nil { - url := dashboards.GetDashboardURL(slugQueryResult.UID, slugQueryResult.Slug) + url := dashboards.GetDashboardURL(preference.HomeDashboardUID, slugQueryResult.Slug) dashRedirect := dtos.DashboardRedirect{RedirectUri: url} return response.JSON(http.StatusOK, &dashRedirect) } diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index b3e402b0f62..2c4406f41c9 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -274,7 +274,8 @@ type FrontendSettingsDTO struct { CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"` CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"` - DateFormats setting.DateFormats `json:"dateFormats,omitempty"` + DateFormats setting.DateFormats `json:"dateFormats,omitempty"` + QuickRanges []setting.QuickRange `json:"quickRanges,omitempty"` LoginError string `json:"loginError,omitempty"` diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index 8a95effc8e2..f26302bd16d 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -10,6 +10,7 @@ type UpdatePrefsCmd struct { Theme string `json:"theme"` // The numerical :id of a favorited dashboard // Default:0 + // Deprecated: Use HomeDashboardUID instead HomeDashboardID int64 `json:"homeDashboardId"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` // Enum: utc,browser @@ -28,6 +29,7 @@ type PatchPrefsCmd struct { Theme *string `json:"theme,omitempty"` // The numerical :id of a favorited dashboard // Default:0 + // Deprecated: Use HomeDashboardUID instead HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` // Enum: utc,browser Timezone *string `json:"timezone,omitempty"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 5ef55403d4d..3a062fedad4 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -247,6 +247,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro TrustedTypesDefaultPolicyEnabled: trustedTypesDefaultPolicyEnabled, CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled, DateFormats: hs.Cfg.DateFormats, + QuickRanges: hs.Cfg.QuickRanges, SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI, EnableFrontendSandboxForPlugins: hs.Cfg.EnableFrontendSandboxForPlugins, PublicDashboardAccessToken: c.PublicDashboardAccessToken, diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 3f42b03ae56..0dbc6e42860 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -30,8 +30,8 @@ func (hs *HTTPServer) SetHomeDashboard(c *contextmodel.ReqContext) response.Resp cmd.UserID = userID cmd.OrgID = c.GetOrgID() - // the default value of HomeDashboardID is taken from input, when HomeDashboardID is set also, - // UID is used in preference to identify dashboard + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query + // nolint:staticcheck dashboardID := cmd.HomeDashboardID if cmd.HomeDashboardUID != nil { query := dashboards.GetDashboardQuery{UID: *cmd.HomeDashboardUID} @@ -44,8 +44,16 @@ func (hs *HTTPServer) SetHomeDashboard(c *contextmodel.ReqContext) response.Resp } dashboardID = queryResult.ID } + } else if cmd.HomeDashboardID != 0 { // nolint:staticcheck + // make sure uid is always set if id is set + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &dashboards.GetDashboardQuery{ID: cmd.HomeDashboardID, OrgID: cmd.OrgID}) // nolint:staticcheck + if err != nil { + return response.Error(http.StatusNotFound, "Dashboard not found", err) + } + cmd.HomeDashboardUID = &queryResult.UID } + // nolint:staticcheck cmd.HomeDashboardID = dashboardID if err := hs.preferenceService.Save(c.Req.Context(), &cmd); err != nil { @@ -76,7 +84,7 @@ func (hs *HTTPServer) GetUserPreferences(c *contextmodel.ReqContext) response.Re // // Update user preferences. // -// Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value. +// Omitting a key (`theme`, `homeDashboardUID`, `timezone`) will cause the current value to be replaced with the system default value. // // Responses: // 200: okResponse @@ -127,6 +135,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te } // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query + // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID if dtoCmd.HomeDashboardUID != nil { query := dashboards.GetDashboardQuery{UID: *dtoCmd.HomeDashboardUID, OrgID: orgID} @@ -141,7 +150,16 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te } dashboardID = &queryResult.ID } + } else if dtoCmd.HomeDashboardID != nil { + // make sure uid is always set if id is set + queryResult, err := hs.DashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: *dtoCmd.HomeDashboardID, OrgID: orgID}) // nolint:staticcheck + if err != nil { + return response.Error(http.StatusNotFound, "Dashboard not found", err) + } + dtoCmd.HomeDashboardUID = &queryResult.UID } + + // nolint:staticcheck dtoCmd.HomeDashboardID = dashboardID patchCmd := pref.PatchPreferenceCommand{ @@ -151,7 +169,8 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te Theme: dtoCmd.Theme, Timezone: dtoCmd.Timezone, WeekStart: dtoCmd.WeekStart, - HomeDashboardID: dtoCmd.HomeDashboardID, + HomeDashboardID: dtoCmd.HomeDashboardID, // nolint:staticcheck + HomeDashboardUID: dtoCmd.HomeDashboardUID, Language: dtoCmd.Language, Locale: dtoCmd.Locale, QueryHistory: dtoCmd.QueryHistory, diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index 3ed1950f3eb..8bbd9e14102 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -37,14 +37,9 @@ func TestAPIEndpoint_GetCurrentOrgPreferences(t *testing.T) { prefService := preftest.NewPreferenceServiceFake() prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} - dashSvc := dashboards.NewFakeDashboardService(t) - qResult := &dashboards.Dashboard{UID: "home", ID: 1} - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) - server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() hs.preferenceService = prefService - hs.DashboardService = dashSvc }) t.Run("AccessControl allows getting org preferences with correct permissions", func(t *testing.T) { @@ -78,9 +73,14 @@ func TestAPIEndpoint_PutCurrentOrgPreferences(t *testing.T) { prefService := preftest.NewPreferenceServiceFake() prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} + dashSvc := dashboards.NewFakeDashboardService(t) + qResult := &dashboards.Dashboard{UID: "home", ID: 1} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) + server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() hs.preferenceService = prefService + hs.DashboardService = dashSvc }) input := strings.NewReader(testUpdateOrgPreferencesCmd) diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 9e95f5220f6..f55209ac1d6 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -154,6 +154,11 @@ var adminCommands = []*cli.Command{ Usage: "Non interactive mode. Just run the migration.", Value: false, }, + &cli.StringFlag{ + Name: "namespace", + Usage: "That's the Unified Storage Namespace.", + Value: "default", + }, }, }, }, diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 79901c746d2..8da13ca5341 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -32,7 +32,9 @@ import ( // ToUnifiedStorage converts dashboards+folders into unified storage func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) error { - namespace := "default" // TODO... from command line + // Take namespace from command line + namespace := c.String("namespace") + ns, err := authlib.ParseNamespace(namespace) if err != nil { return err @@ -65,7 +67,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err migrator := legacy.NewDashboardAccess( legacysql.NewDatabaseProvider(sqlStore), authlib.OrgNamespaceFormatter, - nil, provisioning, sort.ProvideService(), + nil, provisioning, nil, sort.ProvideService(), ) if c.Bool("non-interactive") { diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index 7c08bb14c8b..6fd0225f45e 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -15,6 +15,7 @@ import ( folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -46,9 +47,10 @@ type LegacyMigrator interface { func ProvideLegacyMigrator( sql db.DB, // direct access to tables provisioning provisioning.ProvisioningService, // only needed for dashboard settings + libraryPanelSvc librarypanels.Service, ) LegacyMigrator { dbp := legacysql.NewDatabaseProvider(sql) - return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, sort.ProvideService()) + return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService()) } type BlobStoreInfo struct { diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 555395fbee4..6f04fe12947 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/storage/legacysql" @@ -63,6 +64,8 @@ type dashboardSqlAccess struct { dashStore dashboards.Store dashboardSearchClient legacysearcher.DashboardSearchClient + libraryPanelSvc librarypanels.Service + // Typically one... the server wrapper subscribers []chan *resource.WrittenEvent mutex sync.Mutex @@ -73,6 +76,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, namespacer request.NamespaceMapper, dashStore dashboards.Store, provisioning provisioning.ProvisioningService, + libraryPanelSvc librarypanels.Service, sorter sort.Service, ) DashboardAccess { dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter) @@ -82,6 +86,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, dashStore: dashStore, provisioning: provisioning, dashboardSearchClient: *dashboardSearchClient, + libraryPanelSvc: libraryPanelSvc, log: log.New("dashboard.legacysql"), } } @@ -450,6 +455,17 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das return nil, false, fmt.Errorf("unable to retrieve dashboard after save") } + // TODO: for modes 3+, we need to migrate /api to /apis for library connections, and begin to + // use search to return the connections, rather than the connections table. + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, false, err + } + err = a.libraryPanelSvc.ConnectLibraryPanelsForDashboard(ctx, requester, out) + if err != nil { + return nil, false, err + } + // stash the raw value in context (if requested) finalMeta, err := utils.MetaAccessor(dash) if err != nil { diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 92c09689838..eaa7d40767e 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -39,6 +39,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" @@ -110,6 +111,7 @@ func RegisterAPIService( sorter sort.Service, quotaService quota.Service, folderStore folder.FolderStore, + libraryPanelSvc librarypanels.Service, restConfigProvider apiserver.RestConfigProvider, userService user.Service, ) *DashboardsAPIBuilder { @@ -137,7 +139,7 @@ func RegisterAPIService( folderClient: folderClient, legacy: &DashboardStorage{ - Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, sorter), + Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter), DashboardService: dashboardService, }, reg: reg, diff --git a/pkg/registry/apis/secret/reststorage/secure_value_rest.go b/pkg/registry/apis/secret/reststorage/secure_value_rest.go index b81447ff023..1314e7b43e9 100644 --- a/pkg/registry/apis/secret/reststorage/secure_value_rest.go +++ b/pkg/registry/apis/secret/reststorage/secure_value_rest.go @@ -105,9 +105,9 @@ func (s *SecureValueRest) List(ctx context.Context, options *internalversion.Lis fieldSelector = fields.Everything() } - allowedSecureValues := make([]secretv0alpha1.SecureValue, 0, len(secureValueList.Items)) + allowedSecureValues := make([]secretv0alpha1.SecureValue, 0, len(secureValueList)) - for _, secureValue := range secureValueList.Items { + for _, secureValue := range secureValueList { // Filter by label if labelSelector.Matches(labels.Set(secureValue.Labels)) { // Filter by status.phase diff --git a/pkg/services/annotations/accesscontrol/accesscontrol.go b/pkg/services/annotations/accesscontrol/accesscontrol.go index f2e89acd23b..d315e9076b8 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol.go @@ -63,11 +63,11 @@ func (authz *AuthService) Authorize(ctx context.Context, query annotations.ItemQ var err error if canAccessDashAnnotations { if query.AnnotationID != 0 { - annotationDashboardID, err := authz.getAnnotationDashboard(ctx, query) + annotationDashboardUID, err := authz.getAnnotationDashboard(ctx, query) if err != nil { return nil, ErrAccessControlInternal.Errorf("failed to fetch annotations: %w", err) } - query.DashboardID = annotationDashboardID + query.DashboardUID = annotationDashboardUID } visibleDashboards, err = authz.dashboardsWithVisibleAnnotations(ctx, query) @@ -83,7 +83,7 @@ func (authz *AuthService) Authorize(ctx context.Context, query annotations.ItemQ }, nil } -func (authz *AuthService) getAnnotationDashboard(ctx context.Context, query annotations.ItemQuery) (int64, error) { +func (authz *AuthService) getAnnotationDashboard(ctx context.Context, query annotations.ItemQuery) (string, error) { var items []annotations.Item params := make([]any, 0) err := authz.db.WithDbSession(ctx, func(sess *db.Session) error { @@ -91,7 +91,7 @@ func (authz *AuthService) getAnnotationDashboard(ctx context.Context, query anno SELECT a.id, a.org_id, - a.dashboard_id + a.dashboard_uid FROM annotation as a WHERE a.org_id = ? AND a.id = ? ` @@ -100,13 +100,13 @@ func (authz *AuthService) getAnnotationDashboard(ctx context.Context, query anno return sess.SQL(sql, params...).Find(&items) }) if err != nil { - return 0, err + return "", err } if len(items) == 0 { - return 0, ErrAccessControlInternal.Errorf("annotation not found") + return "", ErrAccessControlInternal.Errorf("annotation not found") } - return items[0].DashboardID, nil + return items[0].DashboardUID, nil } func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, query annotations.ItemQuery) (map[string]int64, error) { @@ -130,11 +130,6 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context, UIDs: []string{query.DashboardUID}, }) } - if query.DashboardID != 0 { - filters = append(filters, searchstore.DashboardIDFilter{ - IDs: []int64{query.DashboardID}, - }) - } dashs, err := authz.dashSvc.SearchDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ OrgId: query.SignedInUser.GetOrgID(), diff --git a/pkg/services/annotations/annotationsimpl/annotations.go b/pkg/services/annotations/annotationsimpl/annotations.go index 496b782be8f..07dff48325a 100644 --- a/pkg/services/annotations/annotationsimpl/annotations.go +++ b/pkg/services/annotations/annotationsimpl/annotations.go @@ -79,6 +79,7 @@ func (r *RepositoryImpl) Find(ctx context.Context, query *annotations.ItemQuery) } // Search without dashboard UID filter is expensive, so check without access control first + // nolint: staticcheck if query.DashboardID == 0 && query.DashboardUID == "" { // Return early if no annotations found, it's not necessary to perform expensive access control filtering res, err := r.reader.Get(ctx, *query, &accesscontrol.AccessResources{ diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index 28ee61f4afb..600e41ce269 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -81,7 +81,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { }), }) - _ = testutil.CreateDashboard(t, sql, cfg, features, dashboards.SaveDashboardCommand{ + dashboard2 := testutil.CreateDashboard(t, sql, cfg, features, dashboards.SaveDashboardCommand{ UserID: 1, OrgID: 1, IsFolder: false, @@ -91,18 +91,20 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { }) dash1Annotation := &annotations.Item{ - OrgID: 1, - DashboardID: 1, - Epoch: 10, + OrgID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard1.UID, + Epoch: 10, } err = repo.Save(context.Background(), dash1Annotation) require.NoError(t, err) dash2Annotation := &annotations.Item{ - OrgID: 1, - DashboardID: 2, - Epoch: 10, - Tags: []string{"foo:bar"}, + OrgID: 1, + DashboardID: 2, // nolint: staticcheck + DashboardUID: dashboard2.UID, + Epoch: 10, + Tags: []string{"foo:bar"}, } err = repo.Save(context.Background(), dash2Annotation) require.NoError(t, err) @@ -292,10 +294,11 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { annotationTxt := fmt.Sprintf("annotation %d", i) dash1Annotation := &annotations.Item{ - OrgID: orgID, - DashboardID: dashboard.ID, - Epoch: 10, - Text: annotationTxt, + OrgID: orgID, + DashboardID: dashboard.ID, // nolint: staticcheck + DashboardUID: dashboard.UID, + Epoch: 10, + Text: annotationTxt, } err = store.Add(context.Background(), dash1Annotation) require.NoError(t, err) diff --git a/pkg/services/annotations/annotationsimpl/cleanup_test.go b/pkg/services/annotations/annotationsimpl/cleanup_test.go index 324699736b1..359ae0e8508 100644 --- a/pkg/services/annotations/annotationsimpl/cleanup_test.go +++ b/pkg/services/annotations/annotationsimpl/cleanup_test.go @@ -3,6 +3,7 @@ package annotationsimpl import ( "context" "errors" + "strconv" "testing" "time" @@ -238,24 +239,27 @@ func createTestAnnotations(t *testing.T, store db.DB, expectedCount int, oldAnno newAnnotationTags := make([]*annotationTag, 0, 2*expectedCount) for i := 0; i < expectedCount; i++ { a := &annotations.Item{ - ID: int64(i + 1), - DashboardID: 1, - OrgID: 1, - UserID: 1, - PanelID: 1, - Text: "", + ID: int64(i + 1), + DashboardID: 1, + DashboardUID: "uid" + strconv.Itoa(i), + OrgID: 1, + UserID: 1, + PanelID: 1, + Text: "", } // mark every third as an API annotation // that does not belong to a dashboard if i%3 == 1 { - a.DashboardID = 0 + a.DashboardID = 0 // nolint: staticcheck + a.DashboardUID = "" } // mark every third annotation as an alert annotation if i%3 == 0 { a.AlertID = 10 - a.DashboardID = 2 + a.DashboardID = 2 // nolint: staticcheck + a.DashboardUID = "dashboard2uid" } // create epoch as int annotations.go line 40 diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store.go b/pkg/services/annotations/annotationsimpl/loki/historian_store.go index 707e9c6bebb..933b7426ab1 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store.go @@ -85,6 +85,7 @@ func (r *LokiHistorianStore) Get(ctx context.Context, query annotations.ItemQuer // if the query is filtering on tags, but not on a specific dashboard, we shouldn't query loki // since state history won't have tags for annotations + // nolint: staticcheck if len(query.Tags) > 0 && query.DashboardID == 0 && query.DashboardUID == "" { return make([]*annotations.ItemDTO, 0), nil } @@ -178,7 +179,7 @@ func (r *LokiHistorianStore) annotationsFromStream(stream historian.Stream, ac a items = append(items, &annotations.ItemDTO{ AlertID: entry.RuleID, - DashboardID: ac.Dashboards[entry.DashboardUID], + DashboardID: ac.Dashboards[entry.DashboardUID], // nolint: staticcheck DashboardUID: &entry.DashboardUID, PanelID: entry.PanelID, NewState: entry.Current, @@ -280,8 +281,10 @@ func buildHistoryQuery(query *annotations.ItemQuery, dashboards map[string]int64 RuleUID: ruleUID, } + // nolint: staticcheck if historyQuery.DashboardUID == "" && query.DashboardID != 0 { for uid, id := range dashboards { + // nolint: staticcheck if query.DashboardID == id { historyQuery.DashboardUID = uid break diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index 1fa642e3731..cf6c7af51e3 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -193,7 +193,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: dashboard1.ID, + DashboardID: dashboard1.ID, // nolint: staticcheck From: start.UnixMilli(), To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), } @@ -243,7 +243,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: dashboard1.ID, + DashboardID: dashboard1.ID, // nolint: staticcheck From: start.Add(-2 * time.Second).UnixMilli(), To: start.Add(-1 * time.Second).UnixMilli(), } @@ -273,7 +273,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: dashboard1.ID, + DashboardID: dashboard1.ID, // nolint: staticcheck From: start.Add(-1 * time.Second).UnixMilli(), // should clamp to start To: start.Add(1 * time.Second).UnixMilli(), } @@ -294,17 +294,17 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { fakeLokiClient.cfg.MaxQueryLength = oldMax }) - t.Run("should sort history by time", func(t *testing.T) { + t.Run("should sort history by time and be able to query by dashboard uid", func(t *testing.T) { fakeLokiClient.rangeQueryRes = []historian.Stream{ historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()), historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()), } query := annotations.ItemQuery{ - OrgID: 1, - DashboardID: dashboard1.ID, - From: start.UnixMilli(), - To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), + OrgID: 1, + DashboardUID: dashboard1.UID, + From: start.UnixMilli(), + To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(), } res, err := store.Get( context.Background(), @@ -393,7 +393,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { expected := &annotations.ItemDTO{ AlertID: rule.ID, - DashboardID: dashboard1.ID, + DashboardID: dashboard1.ID, // nolint: staticcheck DashboardUID: &dashboard1.UID, PanelID: *rule.PanelID, Time: transition.LastEvaluationTime.UnixMilli(), @@ -433,6 +433,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { require.Len(t, items, numTransitions) for _, item := range items { + // nolint: staticcheck require.Equal(t, dashboard1.ID, item.DashboardID) require.Equal(t, dashboard1.UID, *item.DashboardUID) } @@ -464,7 +465,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { for _, item := range items { require.Zero(t, *item.DashboardUID) - require.Zero(t, item.DashboardID) + require.Zero(t, item.DashboardID) // nolint: staticcheck } }) }) @@ -553,7 +554,7 @@ func TestBuildHistoryQuery(t *testing.T) { t.Run("should set dashboard UID from dashboard ID if query does not contain UID", func(t *testing.T) { query := buildHistoryQuery( &annotations.ItemQuery{ - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck }, map[string]int64{ "dashboard-uid": 1, @@ -566,7 +567,7 @@ func TestBuildHistoryQuery(t *testing.T) { t.Run("should skip dashboard UID if missing from query and dashboard map", func(t *testing.T) { query := buildHistoryQuery( &annotations.ItemQuery{ - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck }, map[string]int64{ "other-dashboard-uid": 2, @@ -794,7 +795,7 @@ func compareAnnotationItem(t *testing.T, expected, actual *annotations.ItemDTO) require.Equal(t, expected.PanelID, actual.PanelID) } if expected.DashboardUID != nil { - require.Equal(t, expected.DashboardID, actual.DashboardID) + require.Equal(t, expected.DashboardID, actual.DashboardID) // nolint: staticcheck require.Equal(t, *expected.DashboardUID, *actual.DashboardUID) } require.Equal(t, expected.NewState, actual.NewState) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index 317f9915566..92fb80507c7 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -5,11 +5,11 @@ import ( "context" "errors" "fmt" - "strconv" "strings" "time" "github.com/grafana/grafana/pkg/services/annotations/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -46,6 +46,13 @@ type xormRepositoryImpl struct { } func NewXormStore(cfg *setting.Cfg, l log.Logger, db db.DB, tagService tag.Service) *xormRepositoryImpl { + // populate dashboard_uid at startup, to ensure safe downgrades & upgrades after + // the initial migration occurs + err := migrations.RunDashboardUIDMigrations(db.GetEngine().NewSession(), db.GetEngine().DriverName()) + if err != nil { + l.Error("failed to populate dashboard_uid for annotations", "error", err) + } + return &xormRepositoryImpl{ cfg: cfg, db: db, @@ -255,6 +262,7 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer annotation.id, annotation.epoch as time, annotation.epoch_end as time_end, + annotation.dashboard_uid, annotation.dashboard_id, annotation.panel_id, annotation.new_state, @@ -292,11 +300,18 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer params = append(params, query.AlertUID, query.OrgID) } + // nolint: staticcheck if query.DashboardID != 0 { sql.WriteString(` AND a.dashboard_id = ?`) params = append(params, query.DashboardID) } + // note: orgID is already required above + if query.DashboardUID != "" { + sql.WriteString(` AND a.dashboard_uid = ?`) + params = append(params, query.DashboardUID) + } + if query.PanelID != 0 { sql.WriteString(` AND a.panel_id = ?`) params = append(params, query.PanelID) @@ -351,13 +366,11 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer } } - acFilter, err := r.getAccessControlFilter(query.SignedInUser, accessResources) - if err != nil { - return err - } + acFilter, acParams := r.getAccessControlFilter(query.SignedInUser, accessResources) if acFilter != "" { sql.WriteString(fmt.Sprintf(" AND (%s)", acFilter)) } + params = append(params, acParams...) // order of ORDER BY arguments match the order of a sql index for performance orderBy := " ORDER BY a.org_id, a.epoch_end DESC, a.epoch DESC" @@ -377,41 +390,30 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer return items, err } -func (r *xormRepositoryImpl) getAccessControlFilter(user identity.Requester, accessResources *accesscontrol.AccessResources) (string, error) { +func (r *xormRepositoryImpl) getAccessControlFilter(user identity.Requester, accessResources *accesscontrol.AccessResources) (string, []any) { if accessResources.SkipAccessControlFilter { return "", nil } var filters []string + var params []any if accessResources.CanAccessOrgAnnotations { filters = append(filters, "a.dashboard_id = 0") } if accessResources.CanAccessDashAnnotations { - var dashboardIDs []int64 - for _, id := range accessResources.Dashboards { - dashboardIDs = append(dashboardIDs, id) - } - - var inClause string - if len(dashboardIDs) == 0 { - inClause = "SELECT * FROM (SELECT 0 LIMIT 0) tt" // empty set + if len(accessResources.Dashboards) == 0 { + filters = append(filters, "1=0") // empty set } else { - b := make([]byte, 0, 3*len(dashboardIDs)) - - b = strconv.AppendInt(b, dashboardIDs[0], 10) - for _, num := range dashboardIDs[1:] { - b = append(b, ',') - b = strconv.AppendInt(b, num, 10) + filters = append(filters, fmt.Sprintf("a.dashboard_uid IN (%s)", strings.Repeat("?,", len(accessResources.Dashboards)-1)+"?")) + for uid := range accessResources.Dashboards { + params = append(params, uid) } - - inClause = string(b) } - filters = append(filters, fmt.Sprintf("a.dashboard_id IN (%s)", inClause)) } - return strings.Join(filters, " OR "), nil + return strings.Join(filters, " OR "), params } func (r *xormRepositoryImpl) Delete(ctx context.Context, params *annotations.DeleteParams) error { @@ -433,14 +435,27 @@ func (r *xormRepositoryImpl) Delete(ctx context.Context, params *annotations.Del if _, err := sess.Exec(sql, params.ID, params.OrgID); err != nil { return err } + } else if params.DashboardUID != "" { + annoTagSQL = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_uid = ? AND panel_id = ? AND org_id = ?)" + sql = "DELETE FROM annotation WHERE dashboard_uid = ? AND panel_id = ? AND org_id = ?" + + if _, err := sess.Exec(annoTagSQL, params.DashboardUID, params.PanelID, params.OrgID); err != nil { + return err + } + + if _, err := sess.Exec(sql, params.DashboardUID, params.PanelID, params.OrgID); err != nil { + return err + } } else { annoTagSQL = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?)" sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?" + // nolint: staticcheck if _, err := sess.Exec(annoTagSQL, params.DashboardID, params.PanelID, params.OrgID); err != nil { return err } + // nolint: staticcheck if _, err := sess.Exec(sql, params.DashboardID, params.PanelID, params.OrgID); err != nil { return err } diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 2dc394c392a..015c42a5075 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -78,14 +78,15 @@ func TestIntegrationAnnotations(t *testing.T) { var err error annotation := &annotations.Item{ - OrgID: 1, - UserID: 1, - DashboardID: dashboard.ID, - Text: "hello", - Type: "alert", - Epoch: 10, - Tags: []string{"outage", "error", "type:outage", "server:server-1"}, - Data: simplejson.NewFromAny(map[string]any{"data1": "I am a cool data", "data2": "I am another cool data"}), + OrgID: 1, + UserID: 1, + DashboardID: dashboard.ID, // nolint: staticcheck + DashboardUID: dashboard.UID, + Text: "hello", + Type: "alert", + Epoch: 10, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + Data: simplejson.NewFromAny(map[string]any{"data1": "I am a cool data", "data2": "I am another cool data"}), } err = store.Add(context.Background(), annotation) require.NoError(t, err) @@ -93,14 +94,15 @@ func TestIntegrationAnnotations(t *testing.T) { assert.Equal(t, annotation.Epoch, annotation.EpochEnd) annotation2 := &annotations.Item{ - OrgID: 1, - UserID: 1, - DashboardID: dashboard2.ID, - Text: "hello", - Type: "alert", - Epoch: 21, // Should swap epoch & epochEnd - EpochEnd: 20, - Tags: []string{"outage", "type:outage", "server:server-1", "error"}, + OrgID: 1, + UserID: 1, + DashboardID: dashboard2.ID, // nolint: staticcheck + DashboardUID: dashboard2.UID, + Text: "hello", + Type: "alert", + Epoch: 21, // Should swap epoch & epochEnd + EpochEnd: 20, + Tags: []string{"outage", "type:outage", "server:server-1", "error"}, } err = store.Add(context.Background(), annotation2) require.NoError(t, err) @@ -135,7 +137,31 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can query for annotation by dashboard id", func(t *testing.T) { items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: dashboard.ID, + DashboardID: dashboard.ID, // nolint: staticcheck + From: 0, + To: 15, + SignedInUser: testUser, + }, &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard.UID: dashboard.ID, + }, + CanAccessDashAnnotations: true, + }) + + require.NoError(t, err) + assert.Len(t, items, 1) + + assert.Equal(t, []string{"outage", "error", "type:outage", "server:server-1"}, items[0].Tags) + + assert.GreaterOrEqual(t, items[0].Created, int64(0)) + assert.GreaterOrEqual(t, items[0].Updated, int64(0)) + assert.Equal(t, items[0].Updated, items[0].Created) + }) + + t.Run("Can query for annotation by dashboard uid", func(t *testing.T) { + items, err := store.Get(context.Background(), annotations.ItemQuery{ + OrgID: 1, + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, @@ -234,12 +260,13 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Should not find any when item is outside time range", func(t *testing.T) { accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 12, To: 15, SignedInUser: testUser, @@ -250,12 +277,13 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Should not find one when tag filter does not match", func(t *testing.T) { accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 1, To: 15, Tags: []string{"asd"}, @@ -267,12 +295,13 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Should not find one when type filter does not match", func(t *testing.T) { accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 1, To: 15, Type: "alert", @@ -284,12 +313,13 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Should find one when all tag filters does match", func(t *testing.T) { accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 1, To: 15, // this will exclude the second test annotation Tags: []string{"outage", "error"}, @@ -315,12 +345,13 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Should find one when all key value tag filters does match", func(t *testing.T) { accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 1, To: 15, Tags: []string{"type:outage", "server:server-1"}, @@ -333,13 +364,14 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can update annotation and remove all tags", func(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, } accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), query, accRes) @@ -368,13 +400,14 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can update annotation with new tags", func(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, } accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), query, accRes) @@ -401,13 +434,14 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can update annotation with additional tags", func(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, } accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), query, accRes) @@ -434,13 +468,14 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can update annotations with data", func(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, } accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), query, accRes) @@ -470,13 +505,14 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can delete annotation", func(t *testing.T) { query := annotations.ItemQuery{ OrgID: 1, - DashboardID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: dashboard.UID, From: 0, To: 15, SignedInUser: testUser, } accRes := &annotation_ac.AccessResources{ - Dashboards: map[string]int64{"foo": 1}, + Dashboards: map[string]int64{dashboard.UID: 1}, CanAccessDashAnnotations: true, } items, err := store.Get(context.Background(), query, accRes) @@ -493,14 +529,15 @@ func TestIntegrationAnnotations(t *testing.T) { t.Run("Can delete annotation using dashboard id and panel id", func(t *testing.T) { annotation3 := &annotations.Item{ - OrgID: 1, - UserID: 1, - DashboardID: dashboard2.ID, - Text: "toBeDeletedWithPanelId", - Type: "alert", - Epoch: 11, - Tags: []string{"test"}, - PanelID: 20, + OrgID: 1, + UserID: 1, + DashboardID: dashboard2.ID, // nolint: staticcheck + DashboardUID: dashboard2.UID, + Text: "toBeDeletedWithPanelId", + Type: "alert", + Epoch: 11, + Tags: []string{"test"}, + PanelID: 20, } err = store.Add(context.Background(), annotation3) require.NoError(t, err) @@ -520,9 +557,46 @@ func TestIntegrationAnnotations(t *testing.T) { items, err := store.Get(context.Background(), query, accRes) require.NoError(t, err) - dashboardId := items[0].DashboardID - panelId := items[0].PanelID - err = store.Delete(context.Background(), &annotations.DeleteParams{DashboardID: dashboardId, PanelID: panelId, OrgID: 1}) + // nolint:staticcheck + err = store.Delete(context.Background(), &annotations.DeleteParams{DashboardID: items[0].DashboardID, PanelID: items[0].PanelID, OrgID: 1}) + require.NoError(t, err) + + items, err = store.Get(context.Background(), query, accRes) + require.NoError(t, err) + assert.Empty(t, items) + }) + + t.Run("Can delete annotation using dashboard uid and panel id", func(t *testing.T) { + annotation3 := &annotations.Item{ + OrgID: 1, + UserID: 1, + DashboardUID: dashboard2.UID, + Text: "toBeDeletedWithPanelId", + Type: "alert", + Epoch: 11, + Tags: []string{"test"}, + PanelID: 20, + } + err = store.Add(context.Background(), annotation3) + require.NoError(t, err) + + accRes := &annotation_ac.AccessResources{ + Dashboards: map[string]int64{ + dashboard2.UID: dashboard2.ID, + }, + CanAccessDashAnnotations: true, + } + + query := annotations.ItemQuery{ + OrgID: 1, + AnnotationID: annotation3.ID, + SignedInUser: testUser, + } + items, err := store.Get(context.Background(), query, accRes) + require.NoError(t, err) + + // nolint:staticcheck + err = store.Delete(context.Background(), &annotations.DeleteParams{DashboardUID: *items[0].DashboardUID, PanelID: items[0].PanelID, OrgID: 1}) require.NoError(t, err) items, err = store.Get(context.Background(), query, accRes) @@ -635,15 +709,16 @@ func benchmarkFindTags(b *testing.B, numAnnotations int) { require.NoError(b, err) annotationWithTheTag := annotations.Item{ - ID: int64(numAnnotations) + 1, - OrgID: 1, - UserID: 1, - DashboardID: int64(1), - Text: "hello", - Type: "alert", - Epoch: 10, - Tags: []string{"outage", "error", "type:outage", "server:server-1"}, - Data: simplejson.NewFromAny(map[string]any{"data1": "I am a cool data", "data2": "I am another cool data"}), + ID: int64(numAnnotations) + 1, + OrgID: 1, + UserID: 1, + DashboardID: 1, // nolint: staticcheck + DashboardUID: "uid", + Text: "hello", + Type: "alert", + Epoch: 10, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + Data: simplejson.NewFromAny(map[string]any{"data1": "I am a cool data", "data2": "I am another cool data"}), } err = store.Add(context.Background(), &annotationWithTheTag) require.NoError(b, err) diff --git a/pkg/services/annotations/annotationstest/fake.go b/pkg/services/annotations/annotationstest/fake.go index b4119e57b85..c7983bd4a36 100644 --- a/pkg/services/annotations/annotationstest/fake.go +++ b/pkg/services/annotations/annotationstest/fake.go @@ -26,7 +26,7 @@ func (repo *fakeAnnotationsRepo) Delete(_ context.Context, params *annotations.D delete(repo.annotations, params.ID) } else { for _, v := range repo.annotations { - if params.DashboardID == v.DashboardID && params.PanelID == v.PanelID { + if params.DashboardUID == v.DashboardUID && params.PanelID == v.PanelID { delete(repo.annotations, v.ID) } } @@ -70,7 +70,7 @@ func (repo *fakeAnnotationsRepo) Find(_ context.Context, query *annotations.Item defer repo.mtx.Unlock() if annotation, has := repo.annotations[query.AnnotationID]; has { - return []*annotations.ItemDTO{{ID: annotation.ID, DashboardID: annotation.DashboardID}}, nil + return []*annotations.ItemDTO{{ID: annotation.ID, DashboardID: annotation.DashboardID, DashboardUID: &annotation.DashboardUID}}, nil // nolint: staticcheck } annotations := []*annotations.ItemDTO{{ID: 1, DashboardID: 0}} return annotations, nil diff --git a/pkg/services/annotations/models.go b/pkg/services/annotations/models.go index 9dcd23755ea..b5e295f876c 100644 --- a/pkg/services/annotations/models.go +++ b/pkg/services/annotations/models.go @@ -6,12 +6,13 @@ import ( ) type ItemQuery struct { - OrgID int64 `json:"orgId"` - From int64 `json:"from"` - To int64 `json:"to"` - UserID int64 `json:"userId"` - AlertID int64 `json:"alertId"` - AlertUID string `json:"alertUID"` + OrgID int64 `json:"orgId"` + From int64 `json:"from"` + To int64 `json:"to"` + UserID int64 `json:"userId"` + AlertID int64 `json:"alertId"` + AlertUID string `json:"alertUID"` + // Deprecated: Use DashboardUID and OrgID instead DashboardID int64 `json:"dashboardId"` DashboardUID string `json:"dashboardUID"` PanelID int64 `json:"panelId"` @@ -72,28 +73,32 @@ type GetAnnotationTagsResponse struct { } type DeleteParams struct { - OrgID int64 - ID int64 - DashboardID int64 - PanelID int64 + OrgID int64 + ID int64 + // Deprecated: Use DashboardUID and OrgID instead + DashboardID int64 + DashboardUID string + PanelID int64 } type Item struct { - ID int64 `json:"id" xorm:"pk autoincr 'id'"` - OrgID int64 `json:"orgId" xorm:"org_id"` - UserID int64 `json:"userId" xorm:"user_id"` - DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` - PanelID int64 `json:"panelId" xorm:"panel_id"` - Text string `json:"text"` - AlertID int64 `json:"alertId" xorm:"alert_id"` - PrevState string `json:"prevState"` - NewState string `json:"newState"` - Epoch int64 `json:"epoch"` - EpochEnd int64 `json:"epochEnd"` - Created int64 `json:"created"` - Updated int64 `json:"updated"` - Tags []string `json:"tags"` - Data *simplejson.Json `json:"data"` + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + OrgID int64 `json:"orgId" xorm:"org_id"` + UserID int64 `json:"userId" xorm:"user_id"` + // Deprecated: Use DashboardUID and OrgID instead + DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` + DashboardUID string `json:"dashboardUID" xorm:"dashboard_uid"` + PanelID int64 `json:"panelId" xorm:"panel_id"` + Text string `json:"text"` + AlertID int64 `json:"alertId" xorm:"alert_id"` + PrevState string `json:"prevState"` + NewState string `json:"newState"` + Epoch int64 `json:"epoch"` + EpochEnd int64 `json:"epochEnd"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Tags []string `json:"tags"` + Data *simplejson.Json `json:"data"` // needed until we remove it from db Type string @@ -106,9 +111,10 @@ func (i Item) TableName() string { // swagger:model Annotation type ItemDTO struct { - ID int64 `json:"id" xorm:"id"` - AlertID int64 `json:"alertId" xorm:"alert_id"` - AlertName string `json:"alertName"` + ID int64 `json:"id" xorm:"id"` + AlertID int64 `json:"alertId" xorm:"alert_id"` + AlertName string `json:"alertName"` + // Deprecated: Use DashboardUID and OrgID instead DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` DashboardUID *string `json:"dashboardUID" xorm:"dashboard_uid"` PanelID int64 `json:"panelId" xorm:"panel_id"` @@ -164,7 +170,7 @@ func (a annotationType) String() string { } func (annotation *ItemDTO) GetType() annotationType { - if annotation.DashboardID != 0 { + if annotation.DashboardUID != nil && *annotation.DashboardUID != "" { return Dashboard } return Organization diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts.go index 570676e7c3f..a279c09d9db 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts.go @@ -92,6 +92,12 @@ func (s *Service) getContactPoints(ctx context.Context, signedInUser *user.Signe contactPoints := make([]contactPoint, 0, len(embeddedContactPoints)) for _, embeddedContactPoint := range embeddedContactPoints { + // This happens in the default contact point, and would otherwise fail to migrate because it has no UID. + // If that contact point is edited in any way, an UID is generated. + if embeddedContactPoint.UID == "" { + continue + } + contactPoints = append(contactPoints, contactPoint{ UID: embeddedContactPoint.UID, Name: embeddedContactPoint.Name, diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go index 3ab80d6838e..f6c43b28196 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go @@ -102,14 +102,12 @@ func TestGetContactPoints(t *testing.T) { }, } - defaultEmailContactPointCount := 1 - createdContactPoints := createContactPoints(t, ctx, s, user) contactPoints, err := s.getContactPoints(ctx, user) require.NoError(t, err) require.NotNil(t, contactPoints) - require.Len(t, contactPoints, len(createdContactPoints)+defaultEmailContactPointCount) + require.Len(t, contactPoints, len(createdContactPoints)) }) t.Run("it returns an error when user lacks permission to read contact point secrets", func(t *testing.T) { diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index 123fb1287b5..26caec9e210 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -1,71 +1,112 @@ package featuremgmt import ( + "context" "fmt" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature" ) -const ( - staticProviderType = "static" - goffProviderType = "goff" - - configSectionName = "feature_toggles.openfeature" - contextSectionName = "feature_toggles.openfeature.context" -) - type OpenFeatureService struct { + cfg *setting.Cfg + log log.Logger provider openfeature.FeatureProvider Client openfeature.IClient } func ProvideOpenFeatureService(cfg *setting.Cfg) (*OpenFeatureService, error) { - conf := cfg.Raw.Section(configSectionName) - provType := conf.Key("provider").MustString(staticProviderType) - url := conf.Key("url").MustString("") - key := conf.Key("targetingKey").MustString(cfg.AppURL) - var provider openfeature.FeatureProvider var err error - if provType == goffProviderType { - provider, err = newGOFFProvider(url) + if cfg.OpenFeature.ProviderType == setting.GOFFProviderType { + if cfg.OpenFeature.URL == nil { + return nil, fmt.Errorf("feature provider url is required for GOFFProviderType") + } + + provider, err = newGOFFProvider(cfg.OpenFeature.URL.String()) } else { provider, err = newStaticProvider(cfg) } if err != nil { - return nil, fmt.Errorf("failed to create %s feature provider: %w", provType, err) + return nil, fmt.Errorf("failed to create %s feature provider: %w", cfg.OpenFeature.ProviderType, err) } if err := openfeature.SetProviderAndWait(provider); err != nil { - return nil, fmt.Errorf("failed to set global %s feature provider: %w", provType, err) + return nil, fmt.Errorf("failed to set global %s feature provider: %w", cfg.OpenFeature.ProviderType, err) } - attrs := ctxAttrs(cfg) - openfeature.SetEvaluationContext(openfeature.NewEvaluationContext(key, attrs)) + openfeature.SetEvaluationContext(openfeature.NewEvaluationContext(cfg.OpenFeature.TargetingKey, cfg.OpenFeature.ContextAttrs)) client := openfeature.NewClient("grafana-openfeature-client") - return &OpenFeatureService{ + cfg: cfg, + log: log.New("openfeatureservice"), provider: provider, Client: client, }, nil } -// ctxAttrs uses config.ini [feature_toggles.openfeature.context] section to build the eval context attributes -func ctxAttrs(cfg *setting.Cfg) map[string]any { - ctxConf := cfg.Raw.Section(contextSectionName) - - attrs := map[string]any{} - for _, key := range ctxConf.KeyStrings() { - attrs[key] = ctxConf.Key(key).String() +func (s *OpenFeatureService) EvalFlagWithStaticProvider(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error) { + _, ok := s.provider.(*inMemoryBulkProvider) + if !ok { + return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("not a static provider, request must be sent to open feature service") } - // Some default attributes - if _, ok := attrs["grafana_version"]; !ok { - attrs["grafana_version"] = setting.BuildVersion + result, err := s.Client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) + if err != nil { + return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err) } - return attrs + return result, nil +} + +func (s *OpenFeatureService) EvalAllFlagsWithStaticProvider(ctx context.Context) (OFREPBulkResponse, error) { + p, ok := s.provider.(*inMemoryBulkProvider) + if !ok { + return OFREPBulkResponse{}, fmt.Errorf("not a static provider, request must be sent to open feature service") + } + + flags, err := p.ListFlags() + if err != nil { + return OFREPBulkResponse{}, fmt.Errorf("static provider failed to list all flags: %w", err) + } + + allFlags := make([]OFREPFlag, 0, len(flags)) + for _, flagKey := range flags { + result, err := s.Client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) + if err != nil { + s.log.Error("failed to evaluate flag during bulk evaluation", "flagKey", flagKey, "error", err) + continue + } + + allFlags = append(allFlags, OFREPFlag{ + Key: flagKey, + Value: result.Value, + Reason: "static provider evaluation result", + Variant: result.Variant, + ErrorCode: string(result.ErrorCode), + ErrorDetails: result.ErrorMessage, + }) + } + + return OFREPBulkResponse{Flags: allFlags}, nil +} + +// Bulk evaluation response +type OFREPBulkResponse struct { + Flags []OFREPFlag `json:"flags"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type OFREPFlag struct { + Key string `json:"key"` + Value bool `json:"value"` + Reason string `json:"reason"` + Variant string `json:"variant,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` } diff --git a/pkg/services/featuremgmt/openfeature_test.go b/pkg/services/featuremgmt/openfeature_test.go index 919c3592e6e..7176fc1e6a1 100644 --- a/pkg/services/featuremgmt/openfeature_test.go +++ b/pkg/services/featuremgmt/openfeature_test.go @@ -1,113 +1,62 @@ package featuremgmt import ( + "net/url" "testing" "github.com/grafana/grafana/pkg/setting" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" - "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProvideOpenFeatureManager(t *testing.T) { + u, err := url.Parse("http://localhost:1031") + require.NoError(t, err) + testCases := []struct { name string - cfg string + cfg setting.OpenFeatureSettings expectedProvider string }{ { name: "static provider", - expectedProvider: staticProviderType, + expectedProvider: setting.StaticProviderType, }, { name: "goff provider", - cfg: ` -[feature_toggles.openfeature] -provider = goff -url = http://localhost:1031 -targetingKey = grafana -`, - expectedProvider: goffProviderType, + cfg: setting.OpenFeatureSettings{ + ProviderType: setting.GOFFProviderType, + URL: u, + TargetingKey: "grafana", + }, + expectedProvider: setting.GOFFProviderType, }, { name: "invalid provider", - cfg: ` -[feature_toggles.openfeature] -provider = some_provider -`, - expectedProvider: staticProviderType, + cfg: setting.OpenFeatureSettings{ + ProviderType: "some_provider", + }, + expectedProvider: setting.StaticProviderType, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() - if tc.cfg != "" { - err := cfg.Raw.Append([]byte(tc.cfg)) - require.NoError(t, err) - } + cfg.OpenFeature = tc.cfg p, err := ProvideOpenFeatureService(cfg) require.NoError(t, err) - if tc.expectedProvider == goffProviderType { + if tc.expectedProvider == setting.GOFFProviderType { _, ok := p.provider.(*gofeatureflag.Provider) assert.True(t, ok, "expected provider to be of type goff.Provider") } else { - _, ok := p.provider.(memprovider.InMemoryProvider) + _, ok := p.provider.(*inMemoryBulkProvider) assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider") } }) } } - -func Test_CtxAttrs(t *testing.T) { - testCases := []struct { - name string - conf string - expected map[string]any - }{ - { - name: "empty config - only default attributes should be present", - expected: map[string]any{ - "grafana_version": "", - }, - }, - { - name: "config with some attributes", - conf: ` -[feature_toggles.openfeature.context] -foo = bar -baz = qux -quux = corge`, - expected: map[string]any{ - "foo": "bar", - "baz": "qux", - "quux": "corge", - "grafana_version": "", - }, - }, - { - name: "config with an attribute that overrides a default one", - conf: ` -[feature_toggles.openfeature.context] -grafana_version = 10.0.0 -foo = bar`, - expected: map[string]any{ - "grafana_version": "10.0.0", - "foo": "bar", - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - cfg, err := setting.NewCfgFromBytes([]byte(tc.conf)) - require.NoError(t, err) - - assert.Equal(t, tc.expected, ctxAttrs(cfg)) - }) - } -} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b79febda1c3..9bda392120c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1636,15 +1636,6 @@ var ( HideFromDocs: true, Expression: "true", // enabled by default }, - { - Name: "xrayApplicationSignals", - Description: "Support Application Signals queries in the X-Ray datasource", - Stage: FeatureStageExperimental, - Owner: awsDatasourcesSquad, - FrontendOnly: true, - HideFromAdminPage: true, - HideFromDocs: true, - }, { Name: "multiTenantTempCredentials", Description: "use multi-tenant path for awsTempCredentials", diff --git a/pkg/services/featuremgmt/static_provider.go b/pkg/services/featuremgmt/static_provider.go index 03ae93a8086..a69e7c28b4e 100644 --- a/pkg/services/featuremgmt/static_provider.go +++ b/pkg/services/featuremgmt/static_provider.go @@ -8,6 +8,29 @@ import ( "github.com/open-feature/go-sdk/openfeature/memprovider" ) +// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that +// also allows for bulk evaluation of flags, necessary to proxy OFREP requests. +type inMemoryBulkProvider struct { + memprovider.InMemoryProvider + flags map[string]memprovider.InMemoryFlag +} + +func newInMemoryBulkProvider(flags map[string]memprovider.InMemoryFlag) *inMemoryBulkProvider { + return &inMemoryBulkProvider{ + InMemoryProvider: memprovider.NewInMemoryProvider(flags), + flags: flags, + } +} + +// ListFlags returns a list of all flags registered with the provider. +func (p *inMemoryBulkProvider) ListFlags() ([]string, error) { + keys := make([]string, 0, len(p.flags)) + for key := range p.flags { + keys = append(keys, key) + } + return keys, nil +} + func newStaticProvider(cfg *setting.Cfg) (openfeature.FeatureProvider, error) { confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) if err != nil { @@ -29,7 +52,7 @@ func newStaticProvider(cfg *setting.Cfg) (openfeature.FeatureProvider, error) { } } - return memprovider.NewInMemoryProvider(flags), nil + return newInMemoryBulkProvider(flags), nil } func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { diff --git a/pkg/services/featuremgmt/static_provider_test.go b/pkg/services/featuremgmt/static_provider_test.go index 97ce98fa656..86d0eaa318c 100644 --- a/pkg/services/featuremgmt/static_provider_test.go +++ b/pkg/services/featuremgmt/static_provider_test.go @@ -56,3 +56,37 @@ func provider(t *testing.T, conf []byte) *OpenFeatureService { require.NoError(t, err) return p } + +func Test_CompareStaticProviderWithFeatureManager(t *testing.T) { + cfg := setting.NewCfg() + sec, err := cfg.Raw.NewSection("feature_toggles") + require.NoError(t, err) + _, err = sec.NewKey("ABCD", "true") + require.NoError(t, err) + + p, err := ProvideOpenFeatureService(cfg) + require.NoError(t, err) + + _, ok := p.provider.(*inMemoryBulkProvider) + if !ok { + t.Fatalf("expected inMemoryBulkProvider, got %T", p.provider) + } + + ctx := openfeature.WithTransactionContext(context.Background(), openfeature.NewEvaluationContext("grafana", nil)) + allFlags, err := p.EvalAllFlagsWithStaticProvider(ctx) + require.NoError(t, err) + + openFeatureEnabledFlags := map[string]bool{} + for _, flag := range allFlags.Flags { + if flag.Value { + openFeatureEnabledFlags[flag.Key] = true + } + } + + mgr, err := ProvideManagerService(cfg) + require.NoError(t, err) + + // compare enabled feature flags match between OpenFeature static provider and Feature Manager + enabledFeatureManager := mgr.GetEnabled(ctx) + assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager) +} diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 5d5375d1532..024c22ddd55 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -214,7 +214,6 @@ unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false, extensionSidebar,experimental,@grafana/observability-logs,false,false,true alertingRulePermanentlyDelete,GA,@grafana/alerting-squad,false,false,true alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true -xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,false,false unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 37e9cd6fa94..274dfd44b51 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -867,10 +867,6 @@ const ( // Enables the UI functionality to recover and view deleted alert rules FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted" - // FlagXrayApplicationSignals - // Support Application Signals queries in the X-Ray datasource - FlagXrayApplicationSignals = "xrayApplicationSignals" - // FlagMultiTenantTempCredentials // use multi-tenant path for awsTempCredentials FlagMultiTenantTempCredentials = "multiTenantTempCredentials" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 1b38f0befe7..b07062495f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3465,7 +3465,8 @@ "metadata": { "name": "xrayApplicationSignals", "resourceVersion": "1743693517832", - "creationTimestamp": "2025-04-01T14:42:02Z" + "creationTimestamp": "2025-04-01T14:42:02Z", + "deletionTimestamp": "2025-06-11T11:57:58Z" }, "spec": { "description": "Support Application Signals queries in the X-Ray datasource", diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index c51574aa598..b5e4c469798 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -53,6 +53,10 @@ window.nonce = '[[.Nonce]]'; [[end]] + [[if .Assets.ContentDeliveryURL]] + window.public_cdn_path = '[[.Assets.ContentDeliveryURL]]public/build/'; + [[end]] + window.__grafana_load_failed = function(...args) { console.error('Failed to load Grafana', ...args); }; diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 327f74ea34c..06379c51fb5 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -212,7 +212,7 @@ func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Prefer } else { homePage := s.cfg.HomePage - if prefs.HomeDashboardID == 0 && len(homePage) > 0 { + if prefs.HomeDashboardUID == "" && len(homePage) > 0 { homeUrl = homePage } } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index d1d16790ad0..2f8d0533238 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -3,13 +3,17 @@ package definitions import ( "context" "encoding/json" + "errors" "fmt" "time" "github.com/go-openapi/strfmt" + alertingTemplates "github.com/grafana/alerting/templates" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" + "gopkg.in/yaml.v3" "github.com/grafana/alerting/definition" @@ -263,6 +267,7 @@ type ( PostableApiReceiver = definition.PostableApiReceiver PostableGrafanaReceivers = definition.PostableGrafanaReceivers ReceiverType = definition.ReceiverType + MergeResult = definition.MergeResult ) const ( @@ -643,13 +648,82 @@ type DatasourceUIDReference struct { DatasourceUID string } +type ExtraConfiguration struct { + Identifier string `yaml:"identifier" json:"identifier"` + MergeMatchers config.Matchers `yaml:"merge_matchers" json:"merge_matchers"` + TemplateFiles map[string]string `yaml:"template_files" json:"template_files"` + AlertmanagerConfig PostableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"` +} + +func (c ExtraConfiguration) Validate() error { + if c.Identifier == "" { + return errors.New("identifier is required") + } + if len(c.MergeMatchers) == 0 { + return errors.New("at least one matcher is required") + } + for _, m := range c.MergeMatchers { + if m.Type != labels.MatchEqual { + return errors.New("only matchers with type equal are supported") + } + } + err := c.AlertmanagerConfig.Validate() + if err != nil { + return fmt.Errorf("invalid alertmanager configuration: %w", err) + } + return nil +} + // swagger:model type PostableUserConfig struct { TemplateFiles map[string]string `yaml:"template_files" json:"template_files"` AlertmanagerConfig PostableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"` + ExtraConfigs []ExtraConfiguration `yaml:"extra_config,omitempty" json:"extra_config,omitempty"` amSimple map[string]interface{} `yaml:"-" json:"-"` } +func (c *PostableUserConfig) GetMergedAlertmanagerConfig() (MergeResult, error) { + if len(c.ExtraConfigs) == 0 { + return MergeResult{ + Config: c.AlertmanagerConfig, + }, nil + } + // support only one config for now + mimirCfg := c.ExtraConfigs[0] + opts := definition.MergeOpts{ + DedupSuffix: mimirCfg.Identifier, + SubtreeMatchers: mimirCfg.MergeMatchers, + } + if err := opts.Validate(); err != nil { + return MergeResult{}, fmt.Errorf("invalid merge options: %w", err) + } + return definition.Merge(c.AlertmanagerConfig, mimirCfg.AlertmanagerConfig, opts) // for now support only the first extra config +} + +// GetMergedTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions. +func (c *PostableUserConfig) GetMergedTemplateDefinitions() []alertingTemplates.TemplateDefinition { + out := make([]alertingTemplates.TemplateDefinition, 0, len(c.TemplateFiles)) + for name, tmpl := range c.TemplateFiles { + out = append(out, alertingTemplates.TemplateDefinition{ + Name: name, + Template: tmpl, + Kind: alertingTemplates.GrafanaKind, + }) + } + if len(c.ExtraConfigs) == 0 { + return out + } + // support only one config for now + for name, tmpl := range c.ExtraConfigs[0].TemplateFiles { + out = append(out, alertingTemplates.TemplateDefinition{ + Name: name, + Template: tmpl, + Kind: alertingTemplates.MimirKind, + }) + } + return out +} + func (c *PostableUserConfig) UnmarshalJSON(b []byte) error { type plain PostableUserConfig if err := json.Unmarshal(b, (*plain)(c)); err != nil { @@ -661,6 +735,15 @@ func (c *PostableUserConfig) UnmarshalJSON(b []byte) error { return err } + if len(c.ExtraConfigs) > 1 { + return errors.New("only one extra config is supported") + } + for _, extraConfig := range c.ExtraConfigs { + if err := extraConfig.Validate(); err != nil { + return fmt.Errorf("extra configuration is invalid: %w", err) + } + } + type intermediate struct { AlertmanagerConfig map[string]interface{} `yaml:"alertmanager_config" json:"alertmanager_config"` } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go index 7cc534e77d4..363e91a9b40 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go @@ -6,7 +6,9 @@ import ( "strings" "testing" + alertingTemplates "github.com/grafana/alerting/templates" "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -215,3 +217,201 @@ func Test_RawMessageMarshaling(t *testing.T) { assert.Equal(t, RawMessage(`{"data":"test"}`), n.Field) }) } + +func TestPostableUserConfig_GetMergedAlertmanagerConfig(t *testing.T) { + alertmanagerCfg := PostableApiAlertingConfig{ + Config: Config{ + Route: &Route{ + Receiver: "default", + }, + }, + Receivers: []*PostableApiReceiver{ + { + Receiver: config.Receiver{ + Name: "default", + }, + }, + }, + } + + testCases := []struct { + name string + config PostableUserConfig + expectedError string + }{ + { + name: "no extra configs", + config: PostableUserConfig{ + AlertmanagerConfig: alertmanagerCfg, + }, + }, + { + name: "valid mimir config", + config: PostableUserConfig{ + AlertmanagerConfig: alertmanagerCfg, + ExtraConfigs: []ExtraConfiguration{ + { + Identifier: "mimir-1", + MergeMatchers: config.Matchers{ + { + Type: labels.MatchEqual, + Name: "cluster", + Value: "prod", + }, + }, + AlertmanagerConfig: PostableApiAlertingConfig{ + Config: Config{ + Route: &Route{ + Receiver: "mimir-receiver", + }, + }, + Receivers: []*PostableApiReceiver{ + { + Receiver: config.Receiver{ + Name: "mimir-receiver", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "empty identifier", + config: PostableUserConfig{ + AlertmanagerConfig: alertmanagerCfg, + ExtraConfigs: []ExtraConfiguration{ + { + Identifier: "", + MergeMatchers: config.Matchers{}, + AlertmanagerConfig: PostableApiAlertingConfig{ + Config: Config{ + Route: &Route{ + Receiver: "test", + }, + }, + }, + }, + }, + }, + expectedError: "invalid merge options", + }, + { + name: "bad matcher type", + config: PostableUserConfig{ + AlertmanagerConfig: alertmanagerCfg, + ExtraConfigs: []ExtraConfiguration{ + { + Identifier: "test", + MergeMatchers: config.Matchers{ + { + Type: labels.MatchNotEqual, + Name: "cluster", + Value: "prod", + }, + }, + }, + }, + }, + expectedError: "only equality matchers are allowed", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.config.GetMergedAlertmanagerConfig() + if tc.expectedError != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedError) + } else { + require.NoError(t, err) + require.NotNil(t, result.Config) + } + }) + } +} + +func TestPostableUserConfig_GetMergedTemplateDefinitions(t *testing.T) { + testCases := []struct { + name string + config PostableUserConfig + expectedTemplates int + }{ + { + name: "no templates", + config: PostableUserConfig{ + TemplateFiles: map[string]string{}, + ExtraConfigs: []ExtraConfiguration{}, + }, + expectedTemplates: 0, + }, + { + name: "grafana templates only", + config: PostableUserConfig{ + TemplateFiles: map[string]string{ + "grafana-template1": "{{ define \"test\" }}Hello{{ end }}", + "grafana-template2": "{{ define \"test2\" }}World{{ end }}", + }, + ExtraConfigs: []ExtraConfiguration{}, + }, + expectedTemplates: 2, + }, + { + name: "mimir templates only", + config: PostableUserConfig{ + TemplateFiles: map[string]string{}, + ExtraConfigs: []ExtraConfiguration{ + { + TemplateFiles: map[string]string{ + "mimir-template": "{{ define \"mimir\" }}Mimir{{ end }}", + }, + }, + }, + }, + expectedTemplates: 1, + }, + { + name: "mixed templates", + config: PostableUserConfig{ + TemplateFiles: map[string]string{ + "grafana-template": "{{ define \"grafana\" }}Grafana{{ end }}", + }, + ExtraConfigs: []ExtraConfiguration{ + { + TemplateFiles: map[string]string{ + "mimir-template": "{{ define \"mimir\" }}Mimir{{ end }}", + }, + }, + }, + }, + expectedTemplates: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.config.GetMergedTemplateDefinitions() + require.Len(t, result, tc.expectedTemplates) + + templateMap := make(map[string]string) + kindMap := make(map[string]alertingTemplates.Kind) + for _, tmpl := range result { + templateMap[tmpl.Name] = tmpl.Template + kindMap[tmpl.Name] = tmpl.Kind + } + + for name, content := range tc.config.TemplateFiles { + require.Equal(t, content, templateMap[name]) + require.Equal(t, alertingTemplates.GrafanaKind, kindMap[name]) + } + + if len(tc.config.ExtraConfigs) > 0 { + for name, content := range tc.config.ExtraConfigs[0].TemplateFiles { + require.Equal(t, content, templateMap[name]) + require.Equal(t, alertingTemplates.MimirKind, kindMap[name]) + } + } + }) + } +} diff --git a/pkg/services/ngalert/api/tooling/definitions/contact_points.go b/pkg/services/ngalert/api/tooling/definitions/contact_points.go index 9acbe144e88..7f98ad65c92 100644 --- a/pkg/services/ngalert/api/tooling/definitions/contact_points.go +++ b/pkg/services/ngalert/api/tooling/definitions/contact_points.go @@ -324,6 +324,7 @@ type WebhookIntegration struct { Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"` HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"` + HTTPConfig *HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" hcl:"http_config,block"` Payload *CustomPayload `json:"payload,omitempty" yaml:"payload,omitempty" hcl:"payload,block"` } @@ -343,6 +344,41 @@ type HMACConfig struct { TimestampHeader string `yaml:"timestampHeader,omitempty" json:"timestampHeader,omitempty" hcl:"timestamp_header"` } +// HTTPClientConfig holds common configurations for notifier HTTP clients. +type HTTPClientConfig struct { + OAuth2Config *OAuth2Config `json:"oauth2,omitempty" yaml:"oauth2,omitempty" hcl:"oauth2,block"` +} + +type ProxyConfig struct { + // ProxyURL is the HTTP proxy server to use to connect to the targets. + ProxyURL *string `yaml:"proxy_url,omitempty" json:"proxy_url,omitempty" hcl:"proxy_url"` + // NoProxy contains addresses that should not use a proxy. + NoProxy *string `yaml:"no_proxy,omitempty" json:"no_proxy,omitempty" hcl:"no_proxy"` + // ProxyFromEnvironment uses environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies. + ProxyFromEnvironment *bool `yaml:"proxy_from_environment,omitempty" json:"proxy_from_environment,omitempty" hcl:"proxy_from_environment"` + // ProxyConnectHeader optionally specifies headers to send to proxies during CONNECT requests. + ProxyConnectHeader *map[string]string `yaml:"proxy_connect_header,omitempty" json:"proxy_connect_header,omitempty" hcl:"proxy_connect_header"` +} + +type OAuth2Config struct { + // ClientID is the OAuth2 client ID. + ClientID string `json:"client_id" yaml:"client_id" hcl:"client_id"` + // ClientSecret is the OAuth2 client secret. + ClientSecret *Secret `json:"client_secret" yaml:"client_secret" hcl:"client_secret"` + // TokenURL is the URL to get the OAuth2 token. + TokenURL string `json:"token_url" yaml:"token_url" hcl:"token_url"` + + // Scopes is the optional list of OAuth2 scopes. + Scopes *[]string `json:"scopes,omitempty" yaml:"scopes,omitempty" hcl:"scopes"` + // EndpointParams is the optional map of additional parameters to include in the token request. + EndpointParams *map[string]string `json:"endpoint_params,omitempty" yaml:"endpoint_params,omitempty" hcl:"endpoint_params"` + // TLSConfig is the optional TLS configuration to use for the OAuth2 token request. + TLSConfig *TLSConfig `json:"tls_config,omitempty" yaml:"tls_config,omitempty" hcl:"tls_config,block"` + + // ProxyConfig is the optional proxy configuration to use for the OAuth2 token request. + ProxyConfig *ProxyConfig `json:"proxy_config,omitempty" yaml:"proxy_config,omitempty" hcl:"proxy_config,block"` +} + type WecomIntegration struct { DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"` diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 60d95aaad04..5e1a16485ee 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -191,8 +191,12 @@ func (f IntegrationFieldPath) String() string { return strings.Join(f, ".") } -func (f IntegrationFieldPath) Append(segment string) IntegrationFieldPath { - return append(f, segment) +func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath { + // Copy the existing path to avoid modifying the original slice. + newPath := make(IntegrationFieldPath, len(f)+1) + copy(newPath, f) + newPath[len(newPath)-1] = segment + return newPath } // IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is @@ -250,11 +254,12 @@ func (config *IntegrationConfig) GetSecretFields() []IntegrationFieldPath { func traverseFields(flds map[string]IntegrationField, parentPath IntegrationFieldPath, predicate func(i IntegrationField) bool) []IntegrationFieldPath { var result []IntegrationFieldPath for key, field := range flds { + path := parentPath.With(key) if predicate(field) { - result = append(result, parentPath.Append(key)) + result = append(result, path) } if len(field.Fields) > 0 { - result = append(result, traverseFields(field.Fields, parentPath.Append(key), predicate)...) + result = append(result, traverseFields(field.Fields, path, predicate)...) } } return result diff --git a/pkg/services/ngalert/models/receivers_test.go b/pkg/services/ngalert/models/receivers_test.go index d83f346f0ae..9ca835e7fe9 100644 --- a/pkg/services/ngalert/models/receivers_test.go +++ b/pkg/services/ngalert/models/receivers_test.go @@ -244,11 +244,14 @@ func TestIntegrationConfig(t *testing.T) { allSecrets[key] = struct{}{} } - for field := range config.Fields { - _, isSecret := allSecrets[field] - assert.Equalf(t, isSecret, config.IsSecureField(NewIntegrationFieldPath(field)), "field '%s' is expected to be secret", field) + secretFields := config.GetSecretFields() + for _, path := range secretFields { + _, isSecret := allSecrets[path.String()] + assert.Equalf(t, isSecret, config.IsSecureField(path), "field '%s' is expected to be secret", path) + delete(allSecrets, path.String()) } assert.False(t, config.IsSecureField(IntegrationFieldPath{"__--**unknown_field**--__"})) + assert.Empty(t, allSecrets, "mismatched secret fields for integration type %s: %v", integrationType, allSecrets) }) } diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 44eb8b9a7c5..a19bdaeae2a 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -38,6 +38,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/remote" + remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/sender" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -187,15 +188,30 @@ func (ng *AlertNG) init() error { remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary) if remotePrimary || remoteSecondary { m := ng.Metrics.GetRemoteAlertmanagerMetrics() + smtpCfg := remoteClient.SmtpConfig{ + FromAddress: ng.Cfg.Smtp.FromAddress, + FromName: ng.Cfg.Smtp.FromName, + Host: ng.Cfg.Smtp.Host, + User: ng.Cfg.Smtp.User, + Password: ng.Cfg.Smtp.Password, + EhloIdentity: ng.Cfg.Smtp.EhloIdentity, + StartTLSPolicy: ng.Cfg.Smtp.StartTLSPolicy, + SkipVerify: ng.Cfg.Smtp.SkipVerify, + StaticHeaders: ng.Cfg.Smtp.StaticHeaders, + } + cfg := remote.AlertmanagerConfig{ BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, DefaultConfig: ng.Cfg.UnifiedAlerting.DefaultConfiguration, TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID, URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL, ExternalURL: ng.Cfg.AppURL, - SmtpFrom: ng.Cfg.Smtp.FromAddress, - StaticHeaders: ng.Cfg.Smtp.StaticHeaders, + SmtpConfig: smtpCfg, Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout, + + // TODO: Remove once everything can be sent in the 'smtp_config' field. + SmtpFrom: ng.Cfg.Smtp.FromAddress, + StaticHeaders: ng.Cfg.Smtp.StaticHeaders, } autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, skipInvalid bool) error { return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, skipInvalid) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index aa570fec558..25461277367 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "time" alertingNotify "github.com/grafana/alerting/notify" @@ -53,6 +54,7 @@ type alertmanager struct { Store AlertingStore stateStore stateStore DefaultConfiguration string + decryptFn alertingNotify.GetDecryptedValueFn } // maintenanceOptions represent the options for components that need maintenance on a frequency within the Alertmanager. @@ -149,6 +151,7 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A Store: store, stateStore: stateStore, logger: l.New("component", "alertmanager", opts.TenantKey, opts.TenantID), // similar to what the base does + decryptFn: decryptFn, } return am, nil @@ -312,11 +315,43 @@ func (am *alertmanager) aggregateInhibitMatchers(rules []config.InhibitRule, amu } } +func logMergeResult(l log.Logger, m apimodels.MergeResult) { + if len(m.RenamedReceivers) == 0 && len(m.RenamedTimeIntervals) == 0 { + return + } + + logCtx := make([]any, 0, 4) + if len(m.RenamedTimeIntervals) > 0 { + rcvBuilder := strings.Builder{} + for from, to := range m.RenamedReceivers { + rcvBuilder.WriteString(fmt.Sprintf("'%s'->'%s',", from, to)) + } + logCtx = append(logCtx, "renamedReceivers", fmt.Sprintf("[%s]", rcvBuilder.String()[0:rcvBuilder.Len()-1])) + } + if len(m.RenamedTimeIntervals) > 0 { + rcvBuilder := strings.Builder{} + for from, to := range m.RenamedTimeIntervals { + rcvBuilder.WriteString(fmt.Sprintf("'%s'->'%s',", from, to)) + } + logCtx = append(logCtx, "renamedTimeIntervals", fmt.Sprintf("[%s]", rcvBuilder.String()[0:rcvBuilder.Len()-1])) + } + l.Info("Configurations merged successfully but some resources were renamed", logCtx...) +} + // applyConfig applies a new configuration by re-initializing all components using the configuration provided. // It returns a boolean indicating whether the user config was changed and an error. // It is not safe to call concurrently. func (am *alertmanager) applyConfig(ctx context.Context, cfg *apimodels.PostableUserConfig, skipInvalid bool) (bool, error) { - err := AddAutogenConfig(ctx, am.logger, am.Store, am.Base.TenantID(), &cfg.AlertmanagerConfig, skipInvalid) + mergeResult, err := cfg.GetMergedAlertmanagerConfig() + if err != nil { + return false, fmt.Errorf("failed to get full alertmanager configuration: %w", err) + } + logMergeResult(am.logger, mergeResult) + amConfig := mergeResult.Config + templates := cfg.GetMergedTemplateDefinitions() + + // Now add autogenerated config to the route. + err = AddAutogenConfig(ctx, am.logger, am.Store, am.Base.TenantID(), &amConfig, skipInvalid) if err != nil { return false, err } @@ -335,14 +370,22 @@ func (am *alertmanager) applyConfig(ctx context.Context, cfg *apimodels.Postable return false, nil } + receivers := PostableApiAlertingConfigToApiReceivers(amConfig) + for _, recv := range receivers { + err = patchNewSecureFields(ctx, recv, alertingNotify.DecodeSecretsFromBase64, am.decryptFn) + if err != nil { + return false, err + } + } + am.logger.Info("Applying new configuration to Alertmanager", "configHash", fmt.Sprintf("%x", configHash)) err = am.Base.ApplyConfig(alertingNotify.NotificationsConfiguration{ - RoutingTree: cfg.AlertmanagerConfig.Route.AsAMRoute(), - InhibitRules: cfg.AlertmanagerConfig.InhibitRules, - MuteTimeIntervals: cfg.AlertmanagerConfig.MuteTimeIntervals, - TimeIntervals: cfg.AlertmanagerConfig.TimeIntervals, - Templates: ToTemplateDefinitions(cfg), - Receivers: PostableApiAlertingConfigToApiReceivers(cfg.AlertmanagerConfig), + RoutingTree: amConfig.Route.AsAMRoute(), + InhibitRules: amConfig.InhibitRules, + MuteTimeIntervals: amConfig.MuteTimeIntervals, + TimeIntervals: amConfig.TimeIntervals, + Templates: templates, + Receivers: receivers, DispatcherLimits: &nilLimits{}, Raw: rawConfig, Hash: configHash, @@ -355,6 +398,50 @@ func (am *alertmanager) applyConfig(ctx context.Context, cfg *apimodels.Postable return true, nil } +func patchNewSecureFields(ctx context.Context, api *alertingNotify.APIReceiver, decode alertingNotify.DecodeSecretsFn, decrypt alertingNotify.GetDecryptedValueFn) error { + for _, integration := range api.Integrations { + switch integration.Type { + case "dingding": + err := patchSettingsFromSecureSettings(ctx, integration, "url", decode, decrypt) + if err != nil { + return err + } + } + } + return nil +} + +func patchSettingsFromSecureSettings(ctx context.Context, integration *alertingNotify.GrafanaIntegrationConfig, key string, decode alertingNotify.DecodeSecretsFn, decrypt alertingNotify.GetDecryptedValueFn) error { + if _, ok := integration.SecureSettings[key]; !ok { + return nil + } + decoded, err := decode(integration.SecureSettings) + if err != nil { + return err + } + settings := map[string]any{} + err = json.Unmarshal(integration.Settings, &settings) + if err != nil { + return err + } + currentValue, ok := settings[key] + currentString := "" + if ok { + currentString, _ = currentValue.(string) + } + secretValue := decrypt(ctx, decoded, key, currentString) + if secretValue == currentString { + return nil + } + settings[key] = secretValue + data, err := json.Marshal(settings) + if err != nil { + return err + } + integration.Settings = data + return nil +} + // PutAlerts receives the alerts and then sends them through the corresponding route based on whenever the alert has a receiver embedded or not func (am *alertmanager) PutAlerts(_ context.Context, postableAlerts apimodels.PostableAlerts) error { alerts := make(alertingNotify.PostableAlerts, 0, len(postableAlerts.PostableAlerts)) diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 7033e5f0b8a..eb7b8d9b06f 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -2,16 +2,22 @@ package notifier import ( "context" + "net/url" "testing" "time" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/client_golang/prometheus" + promcfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" @@ -62,3 +68,145 @@ func TestAlertmanager_newAlertmanager(t *testing.T) { am := setupAMTest(t) require.False(t, am.Ready()) } + +func TestAlertmanager_ApplyConfig(t *testing.T) { + basicConfig := func() definitions.PostableApiAlertingConfig { + return definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "default-receiver", + ObjectMatchers: definitions.ObjectMatchers{ + &labels.Matcher{ + Type: labels.MatchEqual, + Name: "__grafana_autogenerated__", + Value: "true", + }, + }, + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: config.Receiver{ + Name: "default-receiver", + }, + }, + }, + } + } + + testCases := []struct { + name string + config *definitions.PostableUserConfig + expectedError string + skipInvalid bool + }{ + { + name: "basic config", + config: &definitions.PostableUserConfig{ + AlertmanagerConfig: basicConfig(), + TemplateFiles: map[string]string{ + "grafana-template": "{{ define \"grafana.title\" }}Alert{{ end }}", + }, + }, + skipInvalid: false, + }, + { + name: "with mimir config", + config: &definitions.PostableUserConfig{ + AlertmanagerConfig: basicConfig(), + TemplateFiles: map[string]string{ + "grafana-template": "{{ define \"grafana.title\" }}Grafana Alert{{ end }}", + }, + ExtraConfigs: []definitions.ExtraConfiguration{ + { + Identifier: "mimir-prod", + MergeMatchers: config.Matchers{ + { + Type: labels.MatchEqual, + Name: "__mimir__", + Value: "true", + }, + }, + TemplateFiles: map[string]string{ + "mimir-template": "{{ define \"mimir.title\" }}Mimir Alert{{ end }}", + }, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "mimir-webhook", + GroupBy: []model.LabelName{"alertname", "cluster"}, + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: config.Receiver{ + Name: "mimir-webhook", + WebhookConfigs: []*config.WebhookConfig{ + { + URL: &config.SecretURL{ + URL: &url.URL{ + Scheme: "https", + Host: "webhook.example.com", + Path: "/alerts", + }, + }, + HTTPConfig: &promcfg.DefaultHTTPClientConfig, + }, + }, + }, + }, + }, + }, + }, + }, + }, + skipInvalid: false, + }, + { + name: "invalid config fails", + config: &definitions.PostableUserConfig{ + AlertmanagerConfig: basicConfig(), + ExtraConfigs: []definitions.ExtraConfiguration{ + { + Identifier: "", // invalid: empty identifier + MergeMatchers: config.Matchers{}, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "test-receiver", + }, + }, + }, + }, + }, + }, + expectedError: "failed to get full alertmanager configuration", + skipInvalid: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + am := setupAMTest(t) + ctx := context.Background() + + changed, err := am.applyConfig(ctx, tc.config, false) + + if tc.expectedError != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedError) + require.False(t, changed) + } else { + require.NoError(t, err) + require.True(t, changed) + + templateDefs := tc.config.GetMergedTemplateDefinitions() + expectedTemplateCount := len(tc.config.TemplateFiles) + if len(tc.config.ExtraConfigs) > 0 { + expectedTemplateCount += len(tc.config.ExtraConfigs[0].TemplateFiles) + } + require.Len(t, templateDefs, expectedTemplateCount) + } + }) + } +} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 5b70d7f58fa..d69fa8fc7f6 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -113,6 +113,163 @@ func GetAvailableNotifiers() []*NotifierPlugin { }, } + tlsSubformOptions := func() []NotifierOption { + return []NotifierOption{ + { + Label: "Disable certificate verification", + Element: ElementTypeCheckbox, + Description: "Do not verify the server's certificate chain and host name.", + PropertyName: "insecureSkipVerify", + Required: false, + }, + { + Label: "CA Certificate", + Element: ElementTypeTextArea, + Description: "Certificate in PEM format to use when verifying the server's certificate chain.", + InputType: InputTypeText, + PropertyName: "caCertificate", + Required: false, + Secure: true, + }, + { + Label: "Client Certificate", + Element: ElementTypeTextArea, + Description: "Client certificate in PEM format to use when connecting to the server.", + InputType: InputTypeText, + PropertyName: "clientCertificate", + Required: false, + Secure: true, + }, + { + Label: "Client Key", + Element: ElementTypeTextArea, + Description: "Client key in PEM format to use when connecting to the server.", + InputType: InputTypeText, + PropertyName: "clientKey", + Required: false, + Secure: true, + }, + } + } + + proxyOption := func() NotifierOption { + return NotifierOption{ // New in 12.1. + Label: "Proxy Config", + PropertyName: "proxy_config", + Description: "Optional proxy configuration.", + Element: ElementTypeSubform, + SubformOptions: []NotifierOption{ + { + Label: "Proxy URL", + PropertyName: "proxy_url", + Description: "HTTP proxy server to use to connect to the targets.", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "https://proxy.example.com", + Required: false, + Secure: false, + }, + { + Label: "Proxy from environment", + PropertyName: "proxy_from_environment", + Description: "Use environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.", + Element: ElementTypeCheckbox, + Required: false, + Secure: false, + }, + { + Label: "No Proxy", + PropertyName: "no_proxy", + Description: "Comma-separated list of addresses that should not use a proxy.", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "example.com,1.2.3.4", + Required: false, + Secure: false, + }, + { + Label: "Proxy Connect Header", + PropertyName: "proxy_connect_header", + Description: "Optional headers to send to proxies during CONNECT requests.", + Element: ElementTypeKeyValueMap, + InputType: InputTypeText, + Required: false, + Secure: false, + }, + }, + } + } + + commonHttpClientOption := func() NotifierOption { + return NotifierOption{ // New in 12.1. + Label: "HTTP Config", + PropertyName: "http_config", + Description: "Common HTTP client options.", + Element: ElementTypeSubform, + SubformOptions: []NotifierOption{ + { // New in 12.1. + Label: "OAuth2", + PropertyName: "oauth2", + Description: "OAuth2 configuration options", + Element: ElementTypeSubform, + SubformOptions: []NotifierOption{ + { + Label: "Token URL", + PropertyName: "token_url", + Element: ElementTypeInput, + Description: "URL for the access token endpoint.", + InputType: InputTypeText, + Required: true, + Secure: false, + }, + { + Label: "Client ID", + PropertyName: "client_id", + Element: ElementTypeInput, + Description: "Client ID to use when authenticating.", + InputType: InputTypeText, + Required: true, + Secure: false, + }, + { + Label: "Client Secret", + PropertyName: "client_secret", + Element: ElementTypeInput, + Description: "Client secret to use when authenticating.", + InputType: InputTypeText, + Required: true, + Secure: true, + }, + { + Label: "Scopes", + PropertyName: "scopes", + Element: ElementStringArray, + Description: "Optional scopes to request when obtaining an access token.", + Required: false, + Secure: false, + }, + { + Label: "Endpoint Parameters", + PropertyName: "endpoint_params", + Element: ElementTypeKeyValueMap, + Description: "Optional parameters to append to the access token request.", + Required: false, + Secure: false, + }, + { + Label: "TLS", + PropertyName: "tls_config", + Description: "Optional TLS configuration options for OAuth2 requests.", + Element: ElementTypeSubform, + SubformOptions: tlsSubformOptions(), + }, + proxyOption(), + }, + }, + }, + } + } + return []*NotifierPlugin{ { Type: "dingding", @@ -127,6 +284,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", PropertyName: "url", Required: true, + Secure: true, }, { Label: "Message Type", @@ -1006,46 +1164,11 @@ func GetAvailableNotifiers() []*NotifierPlugin { }, { - Label: "TLS", - PropertyName: "tlsConfig", - Description: "TLS configuration options", - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Disable certificate verification", - Element: ElementTypeCheckbox, - Description: "Do not verify the server's certificate chain and host name.", - PropertyName: "insecureSkipVerify", - Required: false, - }, - { - Label: "CA Certificate", - Element: ElementTypeTextArea, - Description: "Certificate in PEM format to use when verifying the server's certificate chain.", - InputType: InputTypeText, - PropertyName: "caCertificate", - Required: false, - Secure: true, - }, - { - Label: "Client Certificate", - Element: ElementTypeTextArea, - Description: "Client certificate in PEM format to use when connecting to the server.", - InputType: InputTypeText, - PropertyName: "clientCertificate", - Required: false, - Secure: true, - }, - { - Label: "Client Key", - Element: ElementTypeTextArea, - Description: "Client key in PEM format to use when connecting to the server.", - InputType: InputTypeText, - PropertyName: "clientKey", - Required: false, - Secure: true, - }, - }, + Label: "TLS", + PropertyName: "tlsConfig", + Description: "TLS configuration options", + Element: ElementTypeSubform, + SubformOptions: tlsSubformOptions(), }, { Label: "HMAC Signature", @@ -1083,6 +1206,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { }, }, }, + commonHttpClientOption(), // New in 12.1. }, }, { @@ -1406,7 +1530,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { }, InputType: InputTypeText, Placeholder: "json", - Description: "The format of the message to be sent. If set to 'json', the message will be sent as a JSON object. If set to 'text', the message will be sent as a plain text string. By default json is used.", + Description: "If set to 'json', the notification message is the default JSON payload, and the Message field sets only the message field in the payload. If set to 'text', the Message field defines the entire payload. The default is 'json'.", PropertyName: "messageFormat", Required: false, }, @@ -1422,6 +1546,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { { Label: "Message", Element: ElementTypeTextArea, + Description: "In 'json' Message format, sets the message field of the default JSON payload. In 'text' Message format, defines the entire payload.", Placeholder: alertingTemplates.DefaultMessageEmbed, PropertyName: "message", }, diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels_test.go b/pkg/services/ngalert/notifier/channels_config/available_channels_test.go index 0fe4330faf6..4377e1d542d 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels_test.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels_test.go @@ -11,7 +11,7 @@ func TestGetSecretKeysForContactPointType(t *testing.T) { receiverType string expectedSecretFields []string }{ - {receiverType: "dingding", expectedSecretFields: []string{}}, + {receiverType: "dingding", expectedSecretFields: []string{"url"}}, {receiverType: "kafka", expectedSecretFields: []string{"password"}}, {receiverType: "email", expectedSecretFields: []string{}}, {receiverType: "pagerduty", expectedSecretFields: []string{"integrationKey"}}, @@ -22,7 +22,18 @@ func TestGetSecretKeysForContactPointType(t *testing.T) { {receiverType: "sensugo", expectedSecretFields: []string{"apikey"}}, {receiverType: "teams", expectedSecretFields: []string{}}, {receiverType: "telegram", expectedSecretFields: []string{"bottoken"}}, - {receiverType: "webhook", expectedSecretFields: []string{"password", "authorization_credentials", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey", "hmacConfig.secret"}}, + {receiverType: "webhook", expectedSecretFields: []string{ + "password", + "authorization_credentials", + "tlsConfig.caCertificate", + "tlsConfig.clientCertificate", + "tlsConfig.clientKey", + "hmacConfig.secret", + "http_config.oauth2.client_secret", + "http_config.oauth2.tls_config.caCertificate", + "http_config.oauth2.tls_config.clientCertificate", + "http_config.oauth2.tls_config.clientKey", + }}, {receiverType: "wecom", expectedSecretFields: []string{"url", "secret"}}, {receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}}, {receiverType: "discord", expectedSecretFields: []string{"url"}}, diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index c7488f3d4e9..20994df5e0b 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -5,7 +5,6 @@ import ( "fmt" alertingNotify "github.com/grafana/alerting/notify" - alertingTemplates "github.com/grafana/alerting/templates" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -132,19 +131,6 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf return apiReceivers } -// ToTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions. -func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition { - out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles)) - for name, tmpl := range cfg.TemplateFiles { - out = append(out, alertingTemplates.TemplateDefinition{ - Name: name, - Template: tmpl, - Kind: alertingTemplates.GrafanaKind, - }) - } - return out -} - // Silence-specific compat functions to convert between grafana/alerting and model types. func GettableSilenceToSilence(s alertingNotify.GettableSilence) *models.Silence { diff --git a/pkg/services/ngalert/notifier/testreceivers.go b/pkg/services/ngalert/notifier/testreceivers.go index cbd1ca29f87..187caab480b 100644 --- a/pkg/services/ngalert/notifier/testreceivers.go +++ b/pkg/services/ngalert/notifier/testreceivers.go @@ -24,12 +24,17 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei SecureSettings: gr.SecureSettings, }) } - receivers = append(receivers, &alertingNotify.APIReceiver{ + recv := &alertingNotify.APIReceiver{ ConfigReceiver: r.Receiver, GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ Integrations: integrations, }, - }) + } + err := patchNewSecureFields(ctx, recv, alertingNotify.DecodeSecretsFromBase64, am.decryptFn) + if err != nil { + return nil, 0, err + } + receivers = append(receivers, recv) } a := &alertingNotify.PostableAlert{} if c.Alert != nil { diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go index 48235396938..fed23d7bc69 100644 --- a/pkg/services/ngalert/provisioning/notification_policies.go +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -111,6 +111,11 @@ func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgI revision.Config.AlertmanagerConfig.Route = &tree + _, err = revision.Config.GetMergedAlertmanagerConfig() + if err != nil { + return definitions.Route{}, "", fmt.Errorf("new routing tree is not compatible with extra configuration: %w", err) + } + err = nps.xact.InTransaction(ctx, func(ctx context.Context) error { if err := nps.configStore.Save(ctx, revision, orgID); err != nil { return err diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 5a207d83218..4c59f0c4796 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -70,6 +70,8 @@ type Alertmanager struct { amClient *remoteClient.Alertmanager mimirClient remoteClient.MimirClient + + smtp remoteClient.SmtpConfig } type AlertmanagerConfig struct { @@ -87,15 +89,19 @@ type AlertmanagerConfig struct { // The same flag is used for promoting state. PromoteConfig bool - // SmtpFrom and StaticHeaders are used in email notifications sent by the remote Alertmanager. - SmtpFrom string - StaticHeaders map[string]string + // SmtpConfig has all the necessary settings for the remote Alertmanager to create an email sender. + SmtpConfig remoteClient.SmtpConfig // SyncInterval determines how often we should attempt to synchronize configuration. SyncInterval time.Duration // Timeout for the HTTP client. Timeout time.Duration + + // TODO: Remove once everything can be send in the 'smtp_config' field. + // SmtpFrom and StaticHeaders are used in email notifications sent by the remote Alertmanager. + SmtpFrom string + StaticHeaders map[string]string } func (cfg *AlertmanagerConfig) Validate() error { @@ -131,6 +137,10 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto URL: u, PromoteConfig: cfg.PromoteConfig, ExternalURL: cfg.ExternalURL, + + Smtp: cfg.SmtpConfig, + + // TODO: Remove once everything can be sent in the 'smtp_config' field. SmtpFrom: cfg.SmtpFrom, StaticHeaders: cfg.StaticHeaders, } @@ -200,12 +210,15 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto metrics: metrics, mimirClient: mc, orgID: cfg.OrgID, - smtpFrom: cfg.SmtpFrom, state: store, sender: s, syncInterval: cfg.SyncInterval, tenantID: cfg.TenantID, url: cfg.URL, + smtp: cfg.SmtpConfig, + + // TODO: Remove once it can be sent only in the 'smtp_config' field. + smtpFrom: cfg.SmtpFrom, }, nil } @@ -686,11 +699,34 @@ func (am *Alertmanager) shouldSendConfig(ctx context.Context, hash [16]byte) boo return true } + // TODO: Remove when the from address can be sent only in the 'smtp_config' field. if rc.SmtpFrom != am.smtpFrom { am.log.Debug("SMTP 'from' address is different, sending the configuration to the remote Alertmanager", "remote", rc.SmtpFrom, "local", am.smtpFrom) return true } + // Compare SMTP configs. + if rc.SmtpConfig.EhloIdentity != am.smtp.EhloIdentity || + rc.SmtpConfig.Password != am.smtp.Password || + rc.SmtpConfig.FromAddress != am.smtp.FromAddress || + rc.SmtpConfig.FromName != am.smtp.FromName || + rc.SmtpConfig.Host != am.smtp.Host || + rc.SmtpConfig.SkipVerify != am.smtp.SkipVerify || + rc.SmtpConfig.StartTLSPolicy != am.smtp.StartTLSPolicy || + len(rc.SmtpConfig.StaticHeaders) != len(am.smtp.StaticHeaders) || + rc.SmtpConfig.User != am.smtp.User { + am.log.Debug("SMTP config is different, sending the configuration to the remote Alertmanager") + return true + } + + for k, v := range rc.SmtpConfig.StaticHeaders { + if value, ok := am.smtp.StaticHeaders[k]; !ok || v != value { + am.log.Debug("SMTP static headers are different, sending the configuration to the remote Alertmanager") + return true + } + } + + // Hash and compare Alertmanager configs. rawRemote, err := json.Marshal(rc.GrafanaAlertmanagerConfig) if err != nil { am.log.Error("Unable to marshal the remote Alertmanager configuration for comparison", "err", err) diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index 85f88c1c613..8ecc6c52974 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -188,6 +188,10 @@ func TestApplyConfig(t *testing.T) { PromoteConfig: true, SyncInterval: 1 * time.Hour, ExternalURL: "https://test.grafana.com", + SmtpConfig: client.SmtpConfig{ + FromAddress: "test-instance@grafana.net", + }, + SmtpFrom: "test-instance@grafana.net", StaticHeaders: map[string]string{"Header-1": "Value-1", "Header-2": "Value-2"}, } @@ -259,10 +263,28 @@ func TestApplyConfig(t *testing.T) { require.Equal(t, 3, configSyncs) require.Equal(t, am.smtpFrom, configSent.SmtpFrom) + // Changing fields in the SMTP config should result in the configuration being updated. + cfg.SmtpConfig = client.SmtpConfig{ + EhloIdentity: "test", + FromAddress: "test@test.com", + FromName: "Test Name", + Host: "test:25", + Password: "test", + SkipVerify: true, + StartTLSPolicy: "test", + StaticHeaders: map[string]string{"test": "true"}, + User: "Test User", + } + am, err = NewAlertmanager(context.Background(), cfg, fstore, secretsService.Decrypt, NoopAutogenFn, m, tracing.InitializeTracerForTest()) + require.NoError(t, err) + require.NoError(t, am.ApplyConfig(ctx, config)) + require.Equal(t, 4, configSyncs) + require.Equal(t, am.smtp, configSent.SmtpConfig) + // Failing to add the auto-generated routes should result in an error. _, err = NewAlertmanager(context.Background(), cfg, fstore, secretsService.Decrypt, errAutogenFn, m, tracing.InitializeTracerForTest()) require.ErrorIs(t, err, errTest) - require.Equal(t, 3, configSyncs) + require.Equal(t, 4, configSyncs) } func TestCompareAndSendConfiguration(t *testing.T) { diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index e2137c1feb0..eb955bf987e 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -22,8 +22,11 @@ type UserGrafanaConfig struct { Default bool `json:"default"` Promoted bool `json:"promoted"` ExternalURL string `json:"external_url"` - SmtpFrom string `json:"smtp_from"` - StaticHeaders map[string]string `json:"static_headers"` + SmtpConfig SmtpConfig `json:"smtp_config"` + + // TODO: Remove once everything can be sent in the 'SmtpConfig' field. + SmtpFrom string `json:"smtp_from"` + StaticHeaders map[string]string `json:"static_headers"` } func (mc *Mimir) ShouldPromoteConfig() bool { @@ -57,8 +60,11 @@ func (mc *Mimir) CreateGrafanaAlertmanagerConfig(ctx context.Context, cfg *apimo Default: isDefault, Promoted: mc.promoteConfig, ExternalURL: mc.externalURL, - SmtpFrom: mc.smtpFrom, - StaticHeaders: mc.staticHeaders, + SmtpConfig: mc.smtpConfig, + + // TODO: Remove once everything can be sent only in the 'smtp_config' field. + SmtpFrom: mc.smtpFrom, + StaticHeaders: mc.staticHeaders, }) if err != nil { return err diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go index e2bd3988ead..931e2f7aa3d 100644 --- a/pkg/services/ngalert/remote/client/mimir.go +++ b/pkg/services/ngalert/remote/client/mimir.go @@ -48,10 +48,25 @@ type Mimir struct { metrics *metrics.RemoteAlertmanager promoteConfig bool externalURL string + smtpConfig SmtpConfig + + // TODO: Remove once everything can be sent in the 'smtp' field. smtpFrom string staticHeaders map[string]string } +type SmtpConfig struct { + EhloIdentity string `json:"ehlo_identity"` + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + Host string `json:"host"` + Password string `json:"password"` + SkipVerify bool `json:"skip_verify"` + StartTLSPolicy string `json:"start_tls_policy"` + StaticHeaders map[string]string `json:"static_headers"` + User string `json:"user"` +} + type Config struct { URL *url.URL TenantID string @@ -60,6 +75,9 @@ type Config struct { Logger log.Logger PromoteConfig bool ExternalURL string + Smtp SmtpConfig + + // TODO: Remove once everything can be sent in the 'smtp_config' field. SmtpFrom string StaticHeaders map[string]string } @@ -105,6 +123,9 @@ func New(cfg *Config, metrics *metrics.RemoteAlertmanager, tracer tracing.Tracer metrics: metrics, promoteConfig: cfg.PromoteConfig, externalURL: cfg.ExternalURL, + smtpConfig: cfg.Smtp, + + // TODO: Remove once everything can be sent in the 'smtp_config' field. smtpFrom: cfg.SmtpFrom, staticHeaders: cfg.StaticHeaders, }, nil diff --git a/pkg/services/ngalert/state/historian/annotation_store.go b/pkg/services/ngalert/state/historian/annotation_store.go index d04f8395508..8e50447e8ad 100644 --- a/pkg/services/ngalert/state/historian/annotation_store.go +++ b/pkg/services/ngalert/state/historian/annotation_store.go @@ -38,7 +38,8 @@ func (s *AnnotationServiceStore) Save(ctx context.Context, panel *PanelKey, anno } for i := range annotations { - annotations[i].DashboardID = dashID + annotations[i].DashboardID = dashID // nolint: staticcheck + annotations[i].DashboardUID = panel.dashUID annotations[i].PanelID = panel.panelID } } diff --git a/pkg/services/ngalert/state/persister_sync_rule.go b/pkg/services/ngalert/state/persister_sync_rule.go index 846c23a239d..da2494d3647 100644 --- a/pkg/services/ngalert/state/persister_sync_rule.go +++ b/pkg/services/ngalert/state/persister_sync_rule.go @@ -53,6 +53,7 @@ func (a *SyncRuleStatePersister) Sync(ctx context.Context, span trace.Span, rule LastEvalTime: s.LastEvaluationTime, CurrentStateSince: s.StartsAt, CurrentStateEnd: s.EndsAt, + FiredAt: s.FiredAt, ResolvedAt: s.ResolvedAt, LastSentAt: s.LastSentAt, ResultFingerprint: s.ResultFingerprint.String(), diff --git a/pkg/services/ngalert/state/persister_sync_rule_test.go b/pkg/services/ngalert/state/persister_sync_rule_test.go index b8a1a69057d..534ba678244 100644 --- a/pkg/services/ngalert/state/persister_sync_rule_test.go +++ b/pkg/services/ngalert/state/persister_sync_rule_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" ) func TestSyncRuleStatePersister_Sync(t *testing.T) { @@ -33,6 +34,7 @@ func TestSyncRuleStatePersister_Sync(t *testing.T) { Labels: data.Labels{ "label-1": "value-1", }, + FiredAt: util.Pointer(time.Now()), LastEvaluationTime: time.Now(), StartsAt: time.Now(), EndsAt: time.Now(), @@ -72,6 +74,7 @@ func TestSyncRuleStatePersister_Sync(t *testing.T) { LastEvalTime: s.LastEvaluationTime, CurrentStateSince: s.StartsAt, CurrentStateEnd: s.EndsAt, + FiredAt: s.FiredAt, ResolvedAt: s.ResolvedAt, LastSentAt: s.LastSentAt, ResultFingerprint: s.ResultFingerprint.String(), diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go index 68eca14e268..4d575c8ca1b 100644 --- a/pkg/services/preference/model.go +++ b/pkg/services/preference/model.go @@ -18,19 +18,21 @@ var ErrUnknownCookieType = errutil.BadRequest( ) type Preference struct { - ID int64 `xorm:"pk autoincr 'id'" db:"id"` - OrgID int64 `xorm:"org_id" db:"org_id"` - UserID int64 `xorm:"user_id" db:"user_id"` - TeamID int64 `xorm:"team_id" db:"team_id"` - Teams []int64 `xorm:"extends"` - Version int `db:"version"` - HomeDashboardID int64 `xorm:"home_dashboard_id" db:"home_dashboard_id"` - Timezone string `db:"timezone"` - WeekStart *string `db:"week_start"` - Theme string `db:"theme"` - Created time.Time `db:"created"` - Updated time.Time `db:"updated"` - JSONData *PreferenceJSONData `xorm:"json_data" db:"json_data"` + ID int64 `xorm:"pk autoincr 'id'" db:"id"` + OrgID int64 `xorm:"org_id" db:"org_id"` + UserID int64 `xorm:"user_id" db:"user_id"` + TeamID int64 `xorm:"team_id" db:"team_id"` + Teams []int64 `xorm:"extends"` + Version int `db:"version"` + // Deprecated: Use HomeDashboardUID instead + HomeDashboardID int64 `xorm:"home_dashboard_id" db:"home_dashboard_id"` + HomeDashboardUID string `xorm:"home_dashboard_uid" db:"home_dashboard_uid"` + Timezone string `db:"timezone"` + WeekStart *string `db:"week_start"` + Theme string `db:"theme"` + Created time.Time `db:"created"` + Updated time.Time `db:"updated"` + JSONData *PreferenceJSONData `xorm:"json_data" db:"json_data"` } func (p Preference) Cookies(typ string) bool { @@ -59,6 +61,7 @@ type SavePreferenceCommand struct { OrgID int64 TeamID int64 + // Deprecated: Use HomeDashboardUID instead HomeDashboardID int64 `json:"homeDashboardId,omitempty"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` Timezone string `json:"timezone,omitempty"` @@ -76,6 +79,7 @@ type PatchPreferenceCommand struct { OrgID int64 TeamID int64 + // Deprecated: Use HomeDashboardUID instead HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` Timezone *string `json:"timezone,omitempty"` diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go index 78712e3d306..502bc33a3e9 100644 --- a/pkg/services/preference/prefapi/api.go +++ b/pkg/services/preference/prefapi/api.go @@ -20,6 +20,8 @@ func UpdatePreferencesFor(ctx context.Context, return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query + // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID if dtoCmd.HomeDashboardUID != nil { query := dashboards.GetDashboardQuery{UID: *dtoCmd.HomeDashboardUID, OrgID: orgID} @@ -33,7 +35,15 @@ func UpdatePreferencesFor(ctx context.Context, } dashboardID = queryResult.ID } + } else if dtoCmd.HomeDashboardID != 0 { + // make sure uid is always set if id is set + queryResult, err := dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: dtoCmd.HomeDashboardID, OrgID: orgID}) // nolint:staticcheck + if err != nil { + return response.Error(http.StatusNotFound, "Dashboard not found", err) + } + dtoCmd.HomeDashboardUID = &queryResult.UID } + // nolint:staticcheck dtoCmd.HomeDashboardID = dashboardID saveCmd := pref.SavePreferenceCommand{ @@ -45,6 +55,7 @@ func UpdatePreferencesFor(ctx context.Context, Timezone: dtoCmd.Timezone, WeekStart: dtoCmd.WeekStart, HomeDashboardID: dtoCmd.HomeDashboardID, + HomeDashboardUID: dtoCmd.HomeDashboardUID, QueryHistory: dtoCmd.QueryHistory, CookiePreferences: dtoCmd.Cookies, Navbar: dtoCmd.Navbar, @@ -71,26 +82,15 @@ func GetPreferencesFor(ctx context.Context, return response.Error(http.StatusInternalServerError, "Failed to get preferences", err) } - var dashboardUID string - // when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response - if preference.HomeDashboardID != 0 { - query := dashboards.GetDashboardQuery{ID: preference.HomeDashboardID, OrgID: orgID} - queryResult, err := dashboardService.GetDashboard(ctx, &query) - if err == nil { - dashboardUID = queryResult.UID - } - } - dto := preferences.Spec{} - if preference.WeekStart != nil && *preference.WeekStart != "" { dto.WeekStart = preference.WeekStart } if preference.Theme != "" { dto.Theme = &preference.Theme } - if dashboardUID != "" { - dto.HomeDashboardUID = &dashboardUID + if preference.HomeDashboardUID != "" { + dto.HomeDashboardUID = &preference.HomeDashboardUID } if preference.Timezone != "" { dto.Timezone = &preference.Timezone diff --git a/pkg/services/preference/prefimpl/pref.go b/pkg/services/preference/prefimpl/pref.go index bc850372057..b73e3057f55 100644 --- a/pkg/services/preference/prefimpl/pref.go +++ b/pkg/services/preference/prefimpl/pref.go @@ -26,10 +26,11 @@ func ProvideService(db db.DB, cfg *setting.Cfg) pref.Service { func prefsFromConfig(cfg *setting.Cfg) pref.Preference { return pref.Preference{ - Theme: cfg.DefaultTheme, - Timezone: cfg.DateFormats.DefaultTimezone, - WeekStart: &cfg.DateFormats.DefaultWeekStart, - HomeDashboardID: 0, + Theme: cfg.DefaultTheme, + Timezone: cfg.DateFormats.DefaultTimezone, + WeekStart: &cfg.DateFormats.DefaultWeekStart, + HomeDashboardID: 0, // nolint:staticcheck + HomeDashboardUID: "", JSONData: &pref.PreferenceJSONData{ Language: cfg.DefaultLanguage, }, @@ -59,9 +60,13 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference if p.WeekStart != nil && *p.WeekStart != "" { res.WeekStart = p.WeekStart } + // nolint: staticcheck if p.HomeDashboardID != 0 { res.HomeDashboardID = p.HomeDashboardID } + if p.HomeDashboardUID != "" { + res.HomeDashboardUID = p.HomeDashboardUID + } if p.JSONData != nil { if p.JSONData.Language != "" { res.JSONData.Language = p.JSONData.Language @@ -118,9 +123,10 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err if err != nil { if errors.Is(err, pref.ErrPrefNotFound) { preference := &pref.Preference{ - UserID: cmd.UserID, - OrgID: cmd.OrgID, - TeamID: cmd.TeamID, + UserID: cmd.UserID, + OrgID: cmd.OrgID, + TeamID: cmd.TeamID, + // nolint: staticcheck HomeDashboardID: cmd.HomeDashboardID, Timezone: cmd.Timezone, WeekStart: &cmd.WeekStart, @@ -129,6 +135,11 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err Updated: time.Now(), JSONData: jsonData, } + + if cmd.HomeDashboardUID != nil { + preference.HomeDashboardUID = *cmd.HomeDashboardUID + } + _, err = s.store.Insert(ctx, preference) if err != nil { return err @@ -142,7 +153,10 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err preference.Theme = cmd.Theme preference.Updated = time.Now() preference.Version += 1 - preference.HomeDashboardID = cmd.HomeDashboardID + preference.HomeDashboardID = cmd.HomeDashboardID // nolint:staticcheck + if cmd.HomeDashboardUID != nil { + preference.HomeDashboardUID = *cmd.HomeDashboardUID + } preference.JSONData = jsonData return s.store.Update(ctx, preference) @@ -201,10 +215,15 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e } } + // nolint: staticcheck if cmd.HomeDashboardID != nil { preference.HomeDashboardID = *cmd.HomeDashboardID } + if cmd.HomeDashboardUID != nil { + preference.HomeDashboardUID = *cmd.HomeDashboardUID + } + if cmd.CookiePreferences != nil { cookies, err := parseCookiePreferences(cmd.CookiePreferences) if err != nil { @@ -242,10 +261,11 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e func (s *Service) GetDefaults() *pref.Preference { return &pref.Preference{ - Theme: s.defaults.Theme, - Timezone: s.defaults.Timezone, - WeekStart: s.defaults.WeekStart, - HomeDashboardID: 0, + Theme: s.defaults.Theme, + Timezone: s.defaults.Timezone, + WeekStart: s.defaults.WeekStart, + HomeDashboardID: 0, // nolint:staticcheck + HomeDashboardUID: "", JSONData: &pref.PreferenceJSONData{ Language: s.defaults.JSONData.Language, }, diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go index 85a859935ed..47c174fd453 100644 --- a/pkg/services/preference/prefimpl/pref_test.go +++ b/pkg/services/preference/prefimpl/pref_test.go @@ -41,10 +41,11 @@ func TestGetDefaults(t *testing.T) { t.Run("GetDefaults", func(t *testing.T) { preference := prefService.GetDefaults() expected := &pref.Preference{ - WeekStart: &weekStart, - Theme: "light", - Timezone: "UTC", - HomeDashboardID: 0, + WeekStart: &weekStart, + Theme: "light", + Timezone: "UTC", + HomeDashboardID: 0, // nolint:staticcheck + HomeDashboardUID: "", JSONData: &pref.PreferenceJSONData{ Language: "en-US", }, @@ -59,10 +60,11 @@ func TestGetDefaults(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ - WeekStart: &weekStart, - Theme: "light", - Timezone: "UTC", - HomeDashboardID: 0, + WeekStart: &weekStart, + Theme: "light", + Timezone: "UTC", + HomeDashboardID: 0, // nolint:staticcheck + HomeDashboardUID: "", JSONData: &pref.PreferenceJSONData{ Language: "en-US", }, @@ -85,23 +87,25 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { weekStartTwo := "2" insertPrefs(t, prefService.store, pref.Preference{ - OrgID: 1, - HomeDashboardID: 1, - Theme: "dark", - Timezone: "UTC", - WeekStart: &weekStartOne, + OrgID: 1, + HomeDashboardID: 1, // nolint:staticcheck + Theme: "dark", + Timezone: "UTC", + WeekStart: &weekStartOne, + HomeDashboardUID: "test-uid", JSONData: &pref.PreferenceJSONData{ Language: "en-GB", Locale: "en", }, }, pref.Preference{ - OrgID: 1, - UserID: 1, - HomeDashboardID: 4, - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartTwo, + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, // nolint:staticcheck + HomeDashboardUID: "test-uid4", + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartTwo, JSONData: &pref.PreferenceJSONData{ Language: "en-AU", Locale: "es", @@ -114,10 +118,11 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartTwo, - HomeDashboardID: 4, + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartTwo, + HomeDashboardID: 4, // nolint:staticcheck + HomeDashboardUID: "test-uid4", JSONData: &pref.PreferenceJSONData{ Language: "en-AU", Locale: "es", @@ -129,15 +134,16 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { }) t.Run("ignore other user's preferences", func(t *testing.T) { - prefService.GetDefaults().HomeDashboardID = 1 + prefService.GetDefaults().HomeDashboardID = 1 // nolint:staticcheck query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 2} preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ - Theme: "dark", - Timezone: "UTC", - WeekStart: &weekStartOne, - HomeDashboardID: 1, + Theme: "dark", + Timezone: "UTC", + WeekStart: &weekStartOne, + HomeDashboardID: 1, // nolint:staticcheck + HomeDashboardUID: "test-uid", JSONData: &pref.PreferenceJSONData{ Language: "en-GB", Locale: "en", @@ -298,27 +304,30 @@ func TestGetWithDefaults_teams(t *testing.T) { } insertPrefs(t, prefService.store, pref.Preference{ - OrgID: 1, - HomeDashboardID: 1, - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartOne, + OrgID: 1, + HomeDashboardID: 1, // nolint:staticcheck + HomeDashboardUID: "test-uid", + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartOne, }, pref.Preference{ - OrgID: 1, - TeamID: 2, - HomeDashboardID: 3, - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartTwo, + OrgID: 1, + TeamID: 2, + HomeDashboardID: 3, // nolint:staticcheck + HomeDashboardUID: "test-uid3", + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartTwo, }, pref.Preference{ - OrgID: 1, - TeamID: 3, - HomeDashboardID: 4, - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartTwo, + OrgID: 1, + TeamID: 3, + HomeDashboardID: 4, // nolint:staticcheck + HomeDashboardUID: "test-uid4", + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartTwo, }, ) @@ -326,11 +335,12 @@ func TestGetWithDefaults_teams(t *testing.T) { preferences, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ - Theme: "light", - Timezone: "browser", - WeekStart: &weekStartTwo, - HomeDashboardID: 4, - JSONData: &pref.PreferenceJSONData{}, + Theme: "light", + Timezone: "browser", + WeekStart: &weekStartTwo, + HomeDashboardID: 4, // nolint:staticcheck + HomeDashboardUID: "test-uid4", + JSONData: &pref.PreferenceJSONData{}, } if diff := cmp.Diff(expected, preferences); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) @@ -364,13 +374,15 @@ func TestSave(t *testing.T) { } t.Run("insert", func(t *testing.T) { + testUID := "test-uid5" err := prefService.Save(context.Background(), &pref.SavePreferenceCommand{ - OrgID: 1, - Theme: "dark", - Timezone: "browser", - HomeDashboardID: 5, - WeekStart: "1", + OrgID: 1, + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, // nolint:staticcheck + HomeDashboardUID: &testUID, + WeekStart: "1", }, ) require.NoError(t, err) @@ -381,18 +393,21 @@ func TestSave(t *testing.T) { assert.Zero(t, stored.TeamID) assert.Equal(t, "dark", stored.Theme) assert.Equal(t, "browser", stored.Timezone) - assert.EqualValues(t, 5, stored.HomeDashboardID) + assert.EqualValues(t, 5, stored.HomeDashboardID) // nolint:staticcheck + assert.Equal(t, testUID, stored.HomeDashboardUID) assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 0, stored.Version) }) t.Run("update", func(t *testing.T) { + testEmptyUID := "" err := prefService.Save(context.Background(), &pref.SavePreferenceCommand{ - OrgID: 1, - Timezone: "UTC", - HomeDashboardID: 0, - WeekStart: "1", + OrgID: 1, + Timezone: "UTC", + HomeDashboardID: 0, // nolint:staticcheck + HomeDashboardUID: &testEmptyUID, + WeekStart: "1", }, ) require.NoError(t, err) @@ -403,7 +418,8 @@ func TestSave(t *testing.T) { assert.Zero(t, stored.TeamID) assert.Empty(t, stored.Theme) assert.Equal(t, "UTC", stored.Timezone) - assert.Zero(t, stored.HomeDashboardID) + assert.Zero(t, stored.HomeDashboardID) // nolint:staticcheck + assert.Equal(t, "", stored.HomeDashboardUID) assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 1, stored.Version) }) @@ -422,7 +438,8 @@ func TestSave(t *testing.T) { assert.Zero(t, stored.TeamID) assert.Equal(t, themeValue, stored.Theme) assert.Equal(t, "UTC", stored.Timezone) - assert.Zero(t, stored.HomeDashboardID) + assert.Zero(t, stored.HomeDashboardID) // nolint:staticcheck + assert.Equal(t, "", stored.HomeDashboardUID) assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 2, stored.Version) }) diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index afbfea86240..40015e80425 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -36,56 +36,63 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { t.Run("Get with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { _, err := prefStore.Insert(context.Background(), &pref.Preference{ - OrgID: 1, - UserID: 1, - HomeDashboardID: 4, - TeamID: 2, - Created: time.Now(), - Updated: time.Now(), + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, // nolint:staticcheck + HomeDashboardUID: "test-uid4", + TeamID: 2, + Created: time.Now(), + Updated: time.Now(), }) require.NoError(t, err) query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2} prefs, err := prefStore.Get(context.Background(), query) require.NoError(t, err) - require.Equal(t, int64(4), prefs.HomeDashboardID) + require.Equal(t, int64(4), prefs.HomeDashboardID) // nolint:staticcheck + require.Equal(t, "test-uid4", prefs.HomeDashboardUID) }) t.Run("List with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { _, err := prefStore.Insert(context.Background(), &pref.Preference{ - OrgID: 1, - UserID: 1, - TeamID: 3, - HomeDashboardID: 1, - Created: time.Now(), - Updated: time.Now(), + OrgID: 1, + UserID: 1, + TeamID: 3, + HomeDashboardID: 1, // nolint:staticcheck + HomeDashboardUID: "test-uid1", + Created: time.Now(), + Updated: time.Now(), }) require.NoError(t, err) query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{2}} prefs, err := prefStore.List(context.Background(), query) require.NoError(t, err) - require.Equal(t, int64(4), prefs[0].HomeDashboardID) + require.Equal(t, int64(4), prefs[0].HomeDashboardID) // nolint:staticcheck + require.Equal(t, "test-uid4", prefs[0].HomeDashboardUID) }) t.Run("List with saved org and other user home dashboard should return org home dashboard", func(t *testing.T) { _, err := prefStore.Insert(context.Background(), &pref.Preference{ - OrgID: 1, - UserID: 2, - TeamID: 3, - HomeDashboardID: 1, - Created: time.Now(), - Updated: time.Now(), + OrgID: 1, + UserID: 2, + TeamID: 3, + HomeDashboardID: 1, // nolint:staticcheck + HomeDashboardUID: "test-uid1", + Created: time.Now(), + Updated: time.Now(), }) require.NoError(t, err) query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{3}} prefs, err := prefStore.List(context.Background(), query) require.NoError(t, err) - require.Equal(t, int64(1), prefs[0].HomeDashboardID) - require.Equal(t, int64(1), prefs[1].HomeDashboardID) + require.Equal(t, int64(1), prefs[0].HomeDashboardID) // nolint:staticcheck + require.Equal(t, int64(1), prefs[1].HomeDashboardID) // nolint:staticcheck + require.Equal(t, "test-uid1", prefs[0].HomeDashboardUID) + require.Equal(t, "test-uid1", prefs[1].HomeDashboardUID) }) t.Run("List with saved org and teams home dashboard should return last team home dashboard", func(t *testing.T) { @@ -94,64 +101,74 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { } prefs, err := prefStore.List(context.Background(), query) require.NoError(t, err) - require.Equal(t, int64(4), prefs[0].HomeDashboardID) - require.Equal(t, int64(1), prefs[1].HomeDashboardID) - require.Equal(t, int64(1), prefs[2].HomeDashboardID) + require.Equal(t, int64(4), prefs[0].HomeDashboardID) // nolint:staticcheck + require.Equal(t, int64(1), prefs[1].HomeDashboardID) // nolint:staticcheck + require.Equal(t, int64(1), prefs[2].HomeDashboardID) // nolint:staticcheck + require.Equal(t, "test-uid4", prefs[0].HomeDashboardUID) + require.Equal(t, "test-uid1", prefs[1].HomeDashboardUID) + require.Equal(t, "test-uid1", prefs[2].HomeDashboardUID) }) t.Run("List with saved org and other teams home dashboard should return org home dashboard", func(t *testing.T) { - _, err := prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, HomeDashboardID: 1, Created: time.Now(), Updated: time.Now()}) + // nolint:staticcheck + _, err := prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, HomeDashboardID: 1, HomeDashboardUID: "test-uid1", Created: time.Now(), Updated: time.Now()}) require.NoError(t, err) - _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 2, HomeDashboardID: 2, Created: time.Now(), Updated: time.Now()}) + // nolint:staticcheck + _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 2, HomeDashboardID: 2, HomeDashboardUID: "test-uid2", Created: time.Now(), Updated: time.Now()}) require.NoError(t, err) - _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 3, HomeDashboardID: 3, Created: time.Now(), Updated: time.Now()}) + // nolint:staticcheck + _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 3, HomeDashboardID: 3, HomeDashboardUID: "test-uid3", Created: time.Now(), Updated: time.Now()}) require.NoError(t, err) query := &pref.Preference{OrgID: 1} prefs, err := prefStore.List(context.Background(), query) require.NoError(t, err) - require.Equal(t, int64(1), prefs[0].HomeDashboardID) + require.Equal(t, int64(1), prefs[0].HomeDashboardID) // nolint:staticcheck + require.Equal(t, "test-uid1", prefs[0].HomeDashboardUID) }) t.Run("Update for a user should only modify a single value", func(t *testing.T) { ss := db.InitTestDB(t) prefStore := fn(ss) id, err := prefStore.Insert(context.Background(), &pref.Preference{ - UserID: user.SignedInUser{}.UserID, - Theme: "dark", - Timezone: "browser", - HomeDashboardID: 5, - WeekStart: &weekStartOne, - JSONData: &pref.PreferenceJSONData{}, - Created: time.Now(), - Updated: time.Now(), + UserID: user.SignedInUser{}.UserID, + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, // nolint:staticcheck + HomeDashboardUID: "test-uid5", + WeekStart: &weekStartOne, + JSONData: &pref.PreferenceJSONData{}, + Created: time.Now(), + Updated: time.Now(), }) require.NoError(t, err) err = prefStore.Update(context.Background(), &pref.Preference{ - ID: id, - Theme: "dark", - HomeDashboardID: 5, - Timezone: "browser", - WeekStart: &weekStartOne, - Created: time.Now(), - Updated: time.Now(), - JSONData: &pref.PreferenceJSONData{}, + ID: id, + Theme: "dark", + HomeDashboardID: 5, // nolint:staticcheck + HomeDashboardUID: "test-uid5", + Timezone: "browser", + WeekStart: &weekStartOne, + Created: time.Now(), + Updated: time.Now(), + JSONData: &pref.PreferenceJSONData{}, }) require.NoError(t, err) query := &pref.Preference{} prefs, err := prefStore.List(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ - ID: prefs[0].ID, - Version: prefs[0].Version, - HomeDashboardID: 5, - Timezone: "browser", - WeekStart: &weekStartOne, - Theme: "dark", - JSONData: prefs[0].JSONData, - Created: prefs[0].Created, - Updated: prefs[0].Updated, + ID: prefs[0].ID, + Version: prefs[0].Version, + HomeDashboardID: 5, // nolint:staticcheck + HomeDashboardUID: "test-uid5", + Timezone: "browser", + WeekStart: &weekStartOne, + Theme: "dark", + JSONData: prefs[0].JSONData, + Created: prefs[0].Created, + Updated: prefs[0].Updated, } if diff := cmp.Diff(expected, prefs[0]); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index 676eb96c966..689809aa3e2 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -80,16 +80,17 @@ type AnnotationsDto struct { } type AnnotationEvent struct { - Id int64 `json:"id"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - Tags []string `json:"tags"` - IsRegion bool `json:"isRegion"` - Text string `json:"text"` - Color string `json:"color"` - Time int64 `json:"time"` - TimeEnd int64 `json:"timeEnd"` - Source dashboard.AnnotationQuery `json:"source"` + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + DashboardUID string `json:"dashboardUID"` + PanelId int64 `json:"panelId"` + Tags []string `json:"tags"` + IsRegion bool `json:"isRegion"` + Text string `json:"text"` + Color string `json:"color"` + Time int64 `json:"time"` + TimeEnd int64 `json:"timeEnd"` + Source dashboard.AnnotationQuery `json:"source"` } func (pd PublicDashboard) TableName() string { diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go index 446f74ab3b6..3e21e959722 100644 --- a/pkg/services/publicdashboards/service/query.go +++ b/pkg/services/publicdashboards/service/query.go @@ -55,7 +55,8 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT annoQuery.Limit = anno.Target.Limit annoQuery.MatchAny = anno.Target.MatchAny if anno.Target.Type == "tags" { - annoQuery.DashboardID = 0 + annoQuery.DashboardID = 0 // nolint: staticcheck + annoQuery.DashboardUID = "" annoQuery.Tags = anno.Target.Tags } } @@ -68,7 +69,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT for _, item := range annotationItems { event := models.AnnotationEvent{ Id: item.ID, - DashboardId: item.DashboardID, + DashboardId: item.DashboardID, // nolint: staticcheck Tags: item.Tags, IsRegion: item.TimeEnd > 0 && item.Time != item.TimeEnd, Text: item.Text, @@ -78,6 +79,10 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT Source: anno, } + if item.DashboardUID != nil { + event.DashboardUID = *item.DashboardUID + } + // We want dashboard annotations to reference the panel they're for. If no panelId is provided, they'll show up on all panels // which is only intended for tag and org annotations. if anno.Type != nil && *anno.Type == "dashboard" { diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index 6db2495ae06..896ef21aa8d 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -1,6 +1,8 @@ package migrations import ( + "fmt" + "github.com/grafana/grafana/pkg/util/xorm" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -191,6 +193,12 @@ func addAnnotationMig(mg *Migrator) { mg.AddMigration("Increase new_state column to length 40 not null", NewRawSQLMigration(""). Postgres("ALTER TABLE annotation ALTER COLUMN new_state TYPE VARCHAR(40);"). // Does not modify nullability. Mysql("ALTER TABLE annotation MODIFY new_state VARCHAR(40) NOT NULL;")) + + mg.AddMigration("Add dashboard_uid column to annotation table", NewAddColumnMigration(table, &Column{ + Name: "dashboard_uid", Type: DB_NVarchar, Length: 40, Nullable: true, + })) + + mg.AddMigration("Add missing dashboard_uid to annotation table", &SetDashboardUIDMigration{}) } type AddMakeRegionSingleRowMigration struct { @@ -225,3 +233,40 @@ func (m *AddMakeRegionSingleRowMigration) Exec(sess *xorm.Session, mg *Migrator) _, err = sess.Exec("DELETE FROM annotation WHERE region_id > 0 AND id <> region_id") return err } + +type SetDashboardUIDMigration struct { + MigrationBase +} + +func (m *SetDashboardUIDMigration) SQL(dialect Dialect) string { + return "code migration" +} + +func (m *SetDashboardUIDMigration) Exec(sess *xorm.Session, mg *Migrator) error { + return RunDashboardUIDMigrations(sess, mg.Dialect.DriverName()) +} + +func RunDashboardUIDMigrations(sess *xorm.Session, driverName string) error { + sql := `UPDATE annotation + SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = annotation.dashboard_id) + WHERE dashboard_uid IS NULL AND dashboard_id != 0 AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = annotation.dashboard_id);` + switch driverName { + case Postgres: + sql = `UPDATE annotation + SET dashboard_uid = dashboard.uid + FROM dashboard + WHERE annotation.dashboard_id = dashboard.id + AND annotation.dashboard_id != 0 + AND annotation.dashboard_uid IS NULL;` + case MySQL: + sql = `UPDATE annotation + LEFT 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;` + } + if _, err := sess.Exec(sql); err != nil { + return fmt.Errorf("failed to set dashboard_uid for annotation: %w", err) + } + + return nil +} diff --git a/pkg/services/sqlstore/migrations/preferences_mig.go b/pkg/services/sqlstore/migrations/preferences_mig.go index f348ee9bc27..148d9bb4196 100644 --- a/pkg/services/sqlstore/migrations/preferences_mig.go +++ b/pkg/services/sqlstore/migrations/preferences_mig.go @@ -1,7 +1,10 @@ package migrations import ( + "fmt" + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm" ) func addPreferencesMigrations(mg *Migrator) { @@ -58,4 +61,48 @@ func addPreferencesMigrations(mg *Migrator) { mg.AddMigration("Add preferences index org_id", NewAddIndexMigration(preferencesV2, preferencesV2.Indices[0])) mg.AddMigration("Add preferences index user_id", NewAddIndexMigration(preferencesV2, preferencesV2.Indices[1])) + + mg.AddMigration("Add home_dashboard_uid column to preferences table", NewAddColumnMigration(preferencesV2, &Column{ + Name: "home_dashboard_uid", Type: DB_NVarchar, Length: 40, Nullable: true, + })) + + mg.AddMigration("Add missing dashboard_uid to preferences table", &AddDashboardUIDMigration{}) +} + +type AddDashboardUIDMigration struct { + MigrationBase +} + +func (m *AddDashboardUIDMigration) SQL(dialect Dialect) string { + return "code migration" +} + +func (m *AddDashboardUIDMigration) Exec(sess *xorm.Session, mg *Migrator) error { + return RunPreferencesMigration(sess, mg.Dialect.DriverName()) +} + +func RunPreferencesMigration(sess *xorm.Session, driverName string) error { + // sqlite + sql := `UPDATE preferences + SET home_dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = preferences.home_dashboard_id) + WHERE home_dashboard_uid IS NULL AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = preferences.home_dashboard_id);` + switch driverName { + case Postgres: + sql = `UPDATE preferences + SET home_dashboard_uid = dashboard.uid + FROM dashboard + WHERE preferences.home_dashboard_id = dashboard.id + AND (preferences.home_dashboard_uid IS NULL);` + case MySQL: + sql = `UPDATE preferences + LEFT JOIN dashboard ON preferences.home_dashboard_id = dashboard.id + SET preferences.home_dashboard_uid = dashboard.uid + WHERE preferences.home_dashboard_uid IS NULL;` + } + + if _, err := sess.Exec(sql); err != nil { + return fmt.Errorf("failed to set home_dashboard_uid for preferences: %w", err) + } + + return nil } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index fcab4269a3f..6e4baaefe1b 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -312,6 +312,7 @@ type Cfg struct { Anonymous AnonymousSettings DateFormats DateFormats + QuickRanges QuickRanges // User UserInviteMaxLifetime time.Duration @@ -475,6 +476,9 @@ type Cfg struct { // Query history QueryHistoryEnabled bool + // Open feature settings + OpenFeature OpenFeatureSettings + Storage StorageSettings Search SearchSettings @@ -550,11 +554,13 @@ type Cfg struct { // Unified Storage UnifiedStorage map[string]UnifiedStorageConfig + MaxPageSizeBytes int IndexPath string IndexWorkers int IndexMaxBatchSize int IndexFileThreshold int IndexMinCount int + IndexRebuildInterval time.Duration EnableSharding bool MemberlistBindAddr string MemberlistAdvertiseAddr string @@ -1316,6 +1322,11 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { return err } + if err := cfg.readOpenFeatureSettings(); err != nil { + cfg.Logger.Error("Failed to read open feature settings", "error", err) + return err + } + cfg.readDataSourcesSettings() cfg.readDataSourceSecuritySettings() cfg.readK8sDashboardCleanupSettings() @@ -1388,6 +1399,11 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.ScopesListScopesURL = scopesSection.Key("list_scopes_endpoint").MustString("") cfg.ScopesListDashboardsURL = scopesSection.Key("list_dashboards_endpoint").MustString("") + // Time picker settings + if err := cfg.readTimePicker(); err != nil { + return err + } + // unified storage config cfg.setUnifiedStorageConfig() diff --git a/pkg/setting/setting_openfeature.go b/pkg/setting/setting_openfeature.go new file mode 100644 index 00000000000..13131f89111 --- /dev/null +++ b/pkg/setting/setting_openfeature.go @@ -0,0 +1,51 @@ +package setting + +import ( + "fmt" + "net/url" +) + +const ( + StaticProviderType = "static" + GOFFProviderType = "goff" +) + +type OpenFeatureSettings struct { + ProviderType string + URL *url.URL + TargetingKey string + ContextAttrs map[string]any +} + +func (cfg *Cfg) readOpenFeatureSettings() error { + cfg.OpenFeature = OpenFeatureSettings{} + + config := cfg.Raw.Section("feature_toggles.openfeature") + cfg.OpenFeature.ProviderType = config.Key("provider").MustString(StaticProviderType) + cfg.OpenFeature.TargetingKey = config.Key("targetingKey").MustString(cfg.AppURL) + + strURL := config.Key("url").MustString("") + + if strURL != "" && cfg.OpenFeature.ProviderType == GOFFProviderType { + u, err := url.Parse(strURL) + if err != nil { + return fmt.Errorf("invalid feature provider url: %w", err) + } + cfg.OpenFeature.URL = u + } + + // build the eval context attributes using [feature_toggles.openfeature.context] section + ctxConf := cfg.Raw.Section("feature_toggles.openfeature.context") + attrs := map[string]any{} + for _, key := range ctxConf.KeyStrings() { + attrs[key] = ctxConf.Key(key).String() + } + + // Some default attributes + if _, ok := attrs["grafana_version"]; !ok { + attrs["grafana_version"] = BuildVersion + } + + cfg.OpenFeature.ContextAttrs = attrs + return nil +} diff --git a/pkg/setting/setting_openfeature_test.go b/pkg/setting/setting_openfeature_test.go new file mode 100644 index 00000000000..f03db2f079a --- /dev/null +++ b/pkg/setting/setting_openfeature_test.go @@ -0,0 +1,57 @@ +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CtxAttrs(t *testing.T) { + testCases := []struct { + name string + conf string + expected map[string]any + }{ + { + name: "empty config - only default attributes should be present", + expected: map[string]any{ + "grafana_version": "", + }, + }, + { + name: "config with some attributes", + conf: ` +[feature_toggles.openfeature.context] +foo = bar +baz = qux +quux = corge`, + expected: map[string]any{ + "foo": "bar", + "baz": "qux", + "quux": "corge", + "grafana_version": "", + }, + }, + { + name: "config with an attribute that overrides a default one", + conf: ` +[feature_toggles.openfeature.context] +grafana_version = 10.0.0 +foo = bar`, + expected: map[string]any{ + "grafana_version": "10.0.0", + "foo": "bar", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := NewCfgFromBytes([]byte(tc.conf)) + require.NoError(t, err) + + assert.Equal(t, tc.expected, cfg.OpenFeature.ContextAttrs) + }) + } +} diff --git a/pkg/setting/setting_time_picker.go b/pkg/setting/setting_time_picker.go new file mode 100644 index 00000000000..bae55f00441 --- /dev/null +++ b/pkg/setting/setting_time_picker.go @@ -0,0 +1,57 @@ +package setting + +import ( + "encoding/json" + "fmt" +) + +// QuickRanges is a slice of QuickRange objects that can be directly used in frontend +type QuickRanges []QuickRange + +// QuickRange represents a time range option in the time picker. +// It defines a preset time range that users can select from the time picker dropdown. +type QuickRange struct { + // Display is the user-friendly label shown in the UI for this time range + Display string `json:"display"` + // From is the start of the time range in a format like "now-6h" or an absolute time + From string `json:"from"` + // To is the end of the time range, defaults to "now" if omitted + To string `json:"to,omitempty"` +} + +func (cfg *Cfg) readTimePicker() error { + timePickerSection := cfg.Raw.Section("time_picker") + quickRangesStr := timePickerSection.Key("quick_ranges").String() + + if quickRangesStr == "" { + cfg.QuickRanges = []QuickRange{} + return nil + } + + var quickRanges []QuickRange + err := json.Unmarshal([]byte(quickRangesStr), &quickRanges) + if err != nil { + cfg.Logger.Error("Failed to parse quick_ranges", "error", err) + return fmt.Errorf("failed to parse quick_ranges: %w", err) + } + + // Validate the quick ranges and set defaults + for i, qr := range quickRanges { + if qr.Display == "" { + cfg.Logger.Error("Quick range is missing display name", "index", i) + return fmt.Errorf("quick range at index %d is missing display name", i) + } + if qr.From == "" { + cfg.Logger.Error("Quick range is missing 'from' field", "display", qr.Display) + return fmt.Errorf("quick range '%s' is missing 'from' field", qr.Display) + } + // Set default value for To field if it's empty + if qr.To == "" { + quickRanges[i].To = "now" + } + } + + cfg.QuickRanges = quickRanges + + return nil +} diff --git a/pkg/setting/setting_time_picker_test.go b/pkg/setting/setting_time_picker_test.go new file mode 100644 index 00000000000..50414ced1c5 --- /dev/null +++ b/pkg/setting/setting_time_picker_test.go @@ -0,0 +1,130 @@ +package setting + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" +) + +func TestReadTimePicker(t *testing.T) { + t.Run("Default values when quick_ranges not specified", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.NoError(t, err) + + // Default values should be used + assert.Empty(t, cfg.QuickRanges) + }) + + t.Run("Parse valid quick_ranges", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +quick_ranges = [{"display":"Last 5 minutes","from":"now-5m","to":"now"},{"display":"Yesterday","from":"now-1d/d"},{"display":"Today so far","from":"now/d","to":"now"}] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.NoError(t, err) + + // Validate parsed values + require.Len(t, cfg.QuickRanges, 3) + + // First range + assert.Equal(t, "Last 5 minutes", cfg.QuickRanges[0].Display) + assert.Equal(t, "now-5m", cfg.QuickRanges[0].From) + assert.Equal(t, "now", cfg.QuickRanges[0].To) + + // Second range (defaulted to 'now') + assert.Equal(t, "Yesterday", cfg.QuickRanges[1].Display) + assert.Equal(t, "now-1d/d", cfg.QuickRanges[1].From) + assert.Equal(t, "now", cfg.QuickRanges[1].To) + + // Third range + assert.Equal(t, "Today so far", cfg.QuickRanges[2].Display) + assert.Equal(t, "now/d", cfg.QuickRanges[2].From) + assert.Equal(t, "now", cfg.QuickRanges[2].To) + }) + + t.Run("QuickRange with missing To field gets default value", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +quick_ranges = [{"display":"Yesterday","from":"now-1d/d"}] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.NoError(t, err) + + // Validate the parsed value + require.Len(t, cfg.QuickRanges, 1) + assert.Equal(t, "Yesterday", cfg.QuickRanges[0].Display) + assert.Equal(t, "now-1d/d", cfg.QuickRanges[0].From) + assert.Equal(t, "now", cfg.QuickRanges[0].To) + + jsonBytes, err := json.Marshal(cfg.QuickRanges) + require.NoError(t, err) + assert.Contains(t, string(jsonBytes), "\"to\":\"now\"") + }) + + t.Run("Invalid JSON format", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +quick_ranges = [{"display":"Last 5 minutes","from":"now-5m","to":"now"}, INVALID JSON] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "failed to parse quick_ranges")) + }) + + t.Run("Missing display field", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +quick_ranges = [{"from":"now-5m","to":"now"}] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "missing display name")) + }) + + t.Run("Missing from field", func(t *testing.T) { + cfg := NewCfg() + iniContent := ` +[time_picker] +quick_ranges = [{"display":"Last 5 minutes","to":"now"}] +` + iniFile, err := ini.Load([]byte(iniContent)) + require.NoError(t, err) + cfg.Raw = iniFile + + err = cfg.readTimePicker() + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "missing 'from' field")) + }) +} diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 8da4e942e69..8f07a5cc1eb 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -51,6 +51,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storaae section := cfg.Raw.Section("unified_storage") + cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) cfg.IndexPath = section.Key("index_path").String() cfg.IndexWorkers = section.Key("index_workers").MustInt(10) cfg.IndexMaxBatchSize = section.Key("index_max_batch_size").MustInt(100) @@ -63,6 +64,8 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.InstanceID = section.Key("instance_id").String() cfg.IndexFileThreshold = section.Key("index_file_threshold").MustInt(10) cfg.IndexMinCount = section.Key("index_min_count").MustInt(1) + // default to 24 hours because usage insights summarizes the data every 24 hours + cfg.IndexRebuildInterval = section.Key("index_rebuild_interval").MustDuration(24 * time.Hour) cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(100) cfg.CACertPath = section.Key("ca_cert_path").String() diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index 8cda0f6191d..3eb01bea499 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -67,31 +67,106 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp } func (d *dualWriter) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { - // If we read from unified, we can just do that and return. - if d.readUnified { - return d.unified.List(ctx, options) - } - // If legacy is still the main store, lets first read from it. - legacyList, err := d.legacy.List(ctx, options) + // Always work on *copies* so we never mutate the caller's ListOptions. + var ( + legacyOptions = options.DeepCopy() + unifiedOptions = options.DeepCopy() + log = logging.FromContext(ctx).With("method", "List") + ) + + legacyToken, unifiedToken, err := parseContinueTokens(options.Continue) if err != nil { return nil, err } - // Once we have successfully listed from legacy, we can check if we want to fail on a unified list. - // If we allow the unified list to fail, we can do it in the background and return. - if d.errorIsOK { - go func(ctxBg context.Context, cancel context.CancelFunc) { - defer cancel() - if _, err := d.unified.List(ctxBg, options); err != nil { - log := logging.FromContext(ctxBg).With("method", "List") - log.Error("failed background LIST to unified", "err", err) - } - }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) - return legacyList, nil + + legacyOptions.Continue = legacyToken + unifiedOptions.Continue = unifiedToken + + // If we read from unified, we can just do that and return. + if d.readUnified { + unifiedList, err := d.unified.List(ctx, unifiedOptions) + if err != nil { + return nil, err + } + unifiedMeta, err := meta.ListAccessor(unifiedList) + if err != nil { + return nil, fmt.Errorf("failed to access legacy List MetaData: %w", err) + } + unifiedMeta.SetContinue(buildContinueToken("", unifiedMeta.GetContinue())) + return unifiedList, nil } - // If it's not okay to fail, we have to check it in the foreground. - if _, err := d.unified.List(ctx, options); err != nil { + + // In some cases, the unified token might be there but legacy token is empty (i.e. finished iteration). + // This can happen, as unified storage iteration is doing paging not only based on the provided limit, + // but also based on the response size. This check prevents starting the new iteration again. + if options.Continue != "" && legacyToken == "" { + return nil, nil + } + + // In some cases, where the stores are not in sync yet, the unified storage continue token might already + // be empty, while the legacy one is not, as it has more data. In that case we don't want to issue a new + // request with an empty continue token, resulting in getting the first page again. + // nolint:staticcheck + shouldDoUnifiedRequest := true + if options.Continue != "" && unifiedToken == "" { + shouldDoUnifiedRequest = false + } + + // If legacy is still the main store, lets first read from it. + legacyList, err := d.legacy.List(ctx, legacyOptions) + if err != nil { return nil, err } + legacyMeta, err := meta.ListAccessor(legacyList) + if err != nil { + return nil, fmt.Errorf("failed to access legacy List MetaData: %w", err) + } + legacyToken = legacyMeta.GetContinue() + + // Once we have successfully listed from legacy, we can check if we want to fail on a unified list. + // If we allow the unified list to fail, we can do it in the background and return. + if d.errorIsOK && shouldDoUnifiedRequest { + // We would like to get continue token from unified storage, but + // don't want to wait for unified storage too long, since we're calling + // unified-storage asynchronously. + out := make(chan string, 1) + go func(ctxBg context.Context, cancel context.CancelFunc) { + defer cancel() + defer close(out) + unifiedList, err := d.unified.List(ctxBg, unifiedOptions) + if err != nil { + log.Error("failed background LIST to unified", "err", err) + return + } + unifiedMeta, err := meta.ListAccessor(unifiedList) + if err != nil { + log.Error("failed background LIST to unified", "err", + fmt.Errorf("failed to access unified List MetaData: %w", err)) + } + out <- unifiedMeta.GetContinue() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) + select { + case unifiedToken = <-out: + case <-time.After(300 * time.Millisecond): + log.Warn("timeout while waiting on the unified storage continue token") + break + } + legacyMeta.SetContinue(buildContinueToken(legacyToken, unifiedToken)) + return legacyList, nil + } + if shouldDoUnifiedRequest { + // If it's not okay to fail, we have to check it in the foreground. + unifiedList, err := d.unified.List(ctx, unifiedOptions) + if err != nil { + return nil, err + } + unifiedMeta, err := meta.ListAccessor(unifiedList) + if err != nil { + return nil, fmt.Errorf("failed to access unified List MetaData: %w", err) + } + unifiedToken = unifiedMeta.GetContinue() + } + legacyMeta.SetContinue(buildContinueToken(legacyToken, unifiedToken)) return legacyList, nil } diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_continue_token.go b/pkg/storage/legacysql/dualwrite/dualwriter_continue_token.go new file mode 100644 index 00000000000..31eb8f1bf6e --- /dev/null +++ b/pkg/storage/legacysql/dualwrite/dualwriter_continue_token.go @@ -0,0 +1,33 @@ +package dualwrite + +import ( + "encoding/base64" + "fmt" + "strings" +) + +// parseContinueTokens splits a dualwriter continue token (legacy, unified) if we received one. +// If we receive a single token not separated by a comma, we return the token as-is as a legacy +// token and an empty unified token. This is to ensure a smooth transition to the new token format. +func parseContinueTokens(token string) (string, string, error) { + if token == "" { + return "", "", nil + } + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return "", "", fmt.Errorf("failed to decode dualwriter continue token: %w", err) + } + decodedTokens := strings.Split(string(decodedToken), ",") + if len(decodedTokens) > 1 { + return decodedTokens[0], decodedTokens[1], nil + } + return token, "", nil +} + +func buildContinueToken(legacyToken, unifiedToken string) string { + if legacyToken == "" && unifiedToken == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte( + strings.Join([]string{legacyToken, unifiedToken}, ","))) +} diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_continue_token_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_continue_token_test.go new file mode 100644 index 00000000000..2f79b9167c4 --- /dev/null +++ b/pkg/storage/legacysql/dualwrite/dualwriter_continue_token_test.go @@ -0,0 +1,98 @@ +package dualwrite + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseContinueTokens(t *testing.T) { + tcs := []struct { + name string + token string + legacyToken string + unifiedToken string + }{ + { + name: "Should handle empty token", + token: "", + legacyToken: "", + unifiedToken: "", + }, + { + name: "Should handle legacy token", + token: "MXwy", + legacyToken: "MXwy", + unifiedToken: "", + }, + { + name: "Should handle new token format", + // both slots taken 'MXwy,eyJvIjoxLCJ2IjoxNzQ5NTY1NTU4MDc4OTkwLCJzIjpmYWxzZX0=' + token: "TVh3eSxleUp2SWpveExDSjJJam94TnpRNU5UWTFOVFU0TURjNE9Ua3dMQ0p6SWpwbVlXeHpaWDA9", + legacyToken: "MXwy", + unifiedToken: "eyJvIjoxLCJ2IjoxNzQ5NTY1NTU4MDc4OTkwLCJzIjpmYWxzZX0=", + }, + { + name: "Should handle new token with only unified token (mode >= 3)", + // first slot empty ',eyJvIjoxLCJ2IjoxNzQ5NTY1NTU4MDc4OTkwLCJzIjpmYWxzZX0=' + token: "LGV5SnZJam94TENKMklqb3hOelE1TlRZMU5UVTRNRGM0T1Rrd0xDSnpJanBtWVd4elpYMD0=", + legacyToken: "", + unifiedToken: "eyJvIjoxLCJ2IjoxNzQ5NTY1NTU4MDc4OTkwLCJzIjpmYWxzZX0=", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + legacyToken, unifiedToken, err := parseContinueTokens(tc.token) + require.NoError(t, err) + require.Equal(t, legacyToken, tc.legacyToken) + require.Equal(t, unifiedToken, tc.unifiedToken) + }) + } +} + +func TestBuildContinueToken(t *testing.T) { + tcs := []struct { + name string + legacyToken string + unifiedToken string + shouldBeEmpty bool + }{ + { + name: "Should handle both tokens", + legacyToken: "abc", + unifiedToken: "xyz", + }, + { + name: "Should handle legacy token standalone", + legacyToken: "abc", + }, + { + name: "Should handle unified token standalone", + unifiedToken: "xyz", + }, + { + name: "Should handle both tokens empty", + shouldBeEmpty: true, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + token := buildContinueToken(tc.legacyToken, tc.unifiedToken) + legacyToken, unifiedToken, err := parseContinueTokens(token) + require.NoError(t, err) + require.Equal(t, legacyToken, tc.legacyToken) + require.Equal(t, unifiedToken, tc.unifiedToken) + if tc.shouldBeEmpty { + require.Equal(t, "", token) + } + }) + } +} + +func TestInvalidToken(t *testing.T) { + // nolint: gosec + invalidToken := "325232ff4fF->" + _, _, err := parseContinueTokens(invalidToken) + require.Error(t, err) +} diff --git a/pkg/storage/secret/metadata/data/secure_value_updateStatus.sql b/pkg/storage/secret/metadata/data/secure_value_updateStatus.sql index b0aaa8addf4..dfba1960ce0 100644 --- a/pkg/storage/secret/metadata/data/secure_value_updateStatus.sql +++ b/pkg/storage/secret/metadata/data/secure_value_updateStatus.sql @@ -5,4 +5,4 @@ SET {{ .Ident "status_message" }} = {{ .Arg .Message }} WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND {{ .Ident "name" }} = {{ .Arg .Name }} -; \ No newline at end of file +; diff --git a/pkg/storage/secret/metadata/query.go b/pkg/storage/secret/metadata/query.go index 8c29a525ea1..7ae41fb943b 100644 --- a/pkg/storage/secret/metadata/query.go +++ b/pkg/storage/secret/metadata/query.go @@ -33,6 +33,15 @@ var ( sqlSecureValueUpdateStatus = mustTemplate("secure_value_updateStatus.sql") sqlSecureValueReadForDecrypt = mustTemplate("secure_value_read_for_decrypt.sql") + sqlSecureValueRead = mustTemplate("secure_value_read.sql") + sqlSecureValueList = mustTemplate("secure_value_list.sql") + sqlSecureValueCreate = mustTemplate("secure_value_create.sql") + sqlSecureValueDelete = mustTemplate("secure_value_delete.sql") + sqlSecureValueUpdate = mustTemplate("secure_value_update.sql") + sqlSecureValueUpdateExternalId = mustTemplate("secure_value_updateExternalId.sql") + sqlSecureValueUpdateStatus = mustTemplate("secure_value_updateStatus.sql") + sqlSecureValueReadForDecrypt = mustTemplate("secure_value_read_for_decrypt.sql") + sqlSecureValueOutboxAppend = mustTemplate("secure_value_outbox_append.sql") sqlSecureValueOutboxReceiveN = mustTemplate("secure_value_outbox_receiveN.sql") sqlSecureValueOutboxDelete = mustTemplate("secure_value_outbox_delete.sql") diff --git a/pkg/storage/secret/metadata/query_test.go b/pkg/storage/secret/metadata/query_test.go index e07a8844844..07e33240747 100644 --- a/pkg/storage/secret/metadata/query_test.go +++ b/pkg/storage/secret/metadata/query_test.go @@ -302,6 +302,189 @@ func TestSecureValueQueries(t *testing.T) { }) } +func TestSecureValueQueries(t *testing.T) { + mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ + RootDir: "testdata", + Templates: map[*template.Template][]mocks.TemplateTestCase{ + sqlSecureValueRead: { + { + Name: "read", + Data: &readSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + }, + }, + { + Name: "read-for-update", + Data: &readSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + IsForUpdate: true, + }, + }, + }, + sqlSecureValueList: { + { + Name: "list", + Data: &listSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + }, + }, + }, + sqlSecureValueCreate: { + { + Name: "create-null", + Data: &createSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &secureValueDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Phase: "creating", + Message: toNullString(nil), + Description: "description", + Keeper: toNullString(nil), + Decrypters: toNullString(nil), + Ref: toNullString(nil), + ExternalID: "extId", + }, + }, + }, + { + Name: "create-not-null", + Data: &createSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &secureValueDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Phase: "creating", + Message: toNullString(ptr.To("message_test")), + Description: "description", + Keeper: toNullString(ptr.To("keeper_test")), + Decrypters: toNullString(ptr.To("decrypters_test")), + Ref: toNullString(ptr.To("ref_test")), + ExternalID: "extId", + }, + }, + }, + }, + sqlSecureValueDelete: { + { + Name: "delete", + Data: &deleteSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + }, + }, + }, + sqlSecureValueUpdate: { + { + Name: "update-null", + Data: &updateSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + Row: &secureValueDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Phase: "creating", + Message: toNullString(nil), + Description: "description", + Keeper: toNullString(nil), + Decrypters: toNullString(nil), + Ref: toNullString(nil), + ExternalID: "extId", + }, + }, + }, + { + Name: "update-not-null", + Data: &updateSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + Row: &secureValueDB{ + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Phase: "creating", + Message: toNullString(ptr.To("message_test")), + Description: "description", + Keeper: toNullString(ptr.To("keeper_test")), + Decrypters: toNullString(ptr.To("decrypters_test")), + Ref: toNullString(ptr.To("ref_test")), + ExternalID: "extId", + }, + }, + }, + }, + sqlSecureValueUpdateExternalId: { + { + Name: "updateExternalId", + Data: &updateExternalIdSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + ExternalID: "extId", + }, + }, + }, + sqlSecureValueUpdateStatus: { + { + Name: "updateStatus", + Data: &updateStatusSecureValue{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + Phase: "Succeeded", + Message: "message-1", + }, + }, + }, + sqlSecureValueReadForDecrypt: { + { + Name: "read-for-decrypt", + Data: &readSecureValueForDecrypt{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Name: "name", + Namespace: "ns", + }, + }, + }, + }, + }) +} + func TestSecureValueOutboxQueries(t *testing.T) { mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ RootDir: "testdata", diff --git a/pkg/storage/secret/migrator/migrator.go b/pkg/storage/secret/migrator/migrator.go index 5f23501701e..75bf4f2f486 100644 --- a/pkg/storage/secret/migrator/migrator.go +++ b/pkg/storage/secret/migrator/migrator.go @@ -12,7 +12,7 @@ import ( ) const ( - TableNameKeeper = "secret_keeper" + TableNameKeeper = "secret_keeper" TableNameSecureValue = "secret_secure_value" TableNameSecureValueOutbox = "secret_secure_value_outbox" TableNameDataKey = "secret_data_key" @@ -118,6 +118,36 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { Indices: []*migrator.Index{}, // TODO: add indexes based on the queries we make. }) + tables = append(tables, migrator.Table{ + Name: TableNameSecureValue, + Columns: []*migrator.Column{ + // Kubernetes Metadata + {Name: "guid", Type: migrator.DB_NVarchar, Length: 36, IsPrimaryKey: true}, // Fixed size of a UUID. + {Name: "name", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. + {Name: "annotations", Type: migrator.DB_Text, Nullable: true}, + {Name: "labels", Type: migrator.DB_Text, Nullable: true}, + {Name: "created", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "created_by", Type: migrator.DB_Text, Nullable: false}, + {Name: "updated", Type: migrator.DB_BigInt, Nullable: false}, // Used as RV (ResourceVersion) + {Name: "updated_by", Type: migrator.DB_Text, Nullable: false}, + + // Kubernetes Status + {Name: "status_phase", Type: migrator.DB_Text, Nullable: false}, + {Name: "status_message", Type: migrator.DB_Text, Nullable: true}, + + // Spec + {Name: "description", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Chosen arbitrarily, but should be enough. + {Name: "keeper", Type: migrator.DB_NVarchar, Length: 253, Nullable: true}, // Keeper name, if not set, use default keeper. + {Name: "decrypters", Type: migrator.DB_Text, Nullable: true}, + {Name: "ref", Type: migrator.DB_NVarchar, Length: 1024, Nullable: true}, // Reference to third-party storage secret path.Chosen arbitrarily, but should be enough. + {Name: "external_id", Type: migrator.DB_Text, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"namespace", "name"}, Type: migrator.UniqueIndex}, + }, + }) + tables = append(tables, migrator.Table{ Name: TableNameEncryptedValue, Columns: []*migrator.Column{ diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 421a104beb8..b6ecfe0bd72 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -121,6 +121,9 @@ type searchSupport struct { // testing clientIndexEventsChan chan *IndexEvent + + // periodic rebuilding of the indexes to keep usage insights up to date + rebuildInterval time.Duration } var ( @@ -153,6 +156,7 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.A clientIndexEventsChan: opts.IndexEventsChan, indexEventsChan: make(chan *IndexEvent), indexQueueProcessors: make(map[string]*indexQueueProcessor), + rebuildInterval: opts.RebuildInterval, } info, err := opts.Resources.GetDocumentBuilders() @@ -377,34 +381,52 @@ func (s *searchSupport) GetStats(ctx context.Context, req *resourcepb.ResourceSt return rsp, nil } -// init is called during startup. any failure will block startup and continued execution -func (s *searchSupport) init(ctx context.Context) error { - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init") - defer span.End() - start := time.Now().Unix() - +func (s *searchSupport) buildIndexes(ctx context.Context, rebuild bool) (int, error) { totalBatchesIndexed := 0 group := errgroup.Group{} group.SetLimit(s.initWorkers) stats, err := s.storage.GetResourceStats(ctx, "", s.initMinSize) if err != nil { - return err + return 0, err } for _, info := range stats { + // only periodically rebuild the dashboard index, specifically to update the usage insights data + if rebuild && info.Resource != dashboardv1.DASHBOARD_RESOURCE { + continue + } + group.Go(func() error { - s.log.Debug("initializing search index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource) + if rebuild { + // we need to clear the cache to make sure we get the latest usage insights data + s.builders.clearNamespacedCache(info.NamespacedResource) + } + s.log.Debug("building index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource) totalBatchesIndexed++ - _, _, err = s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion) + _, _, err := s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion) return err }) } err = group.Wait() + if err != nil { + return totalBatchesIndexed, err + } + + return totalBatchesIndexed, nil +} + +func (s *searchSupport) init(ctx context.Context) error { + ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init") + defer span.End() + start := time.Now().Unix() + + totalBatchesIndexed, err := s.buildIndexes(ctx, false) if err != nil { return err } + span.AddEvent("namespaces indexed", trace.WithAttributes(attribute.Int("namespaced_indexed", totalBatchesIndexed))) // Now start listening for new events @@ -428,6 +450,12 @@ func (s *searchSupport) init(ctx context.Context) error { go s.monitorIndexEvents(ctx) + // since usage insights is not in unified storage, we need to periodically rebuild the index + // to make sure these data points are up to date. + if s.rebuildInterval > 0 { + go s.startPeriodicRebuild(watchctx) + } + end := time.Now().Unix() s.log.Info("search index initialized", "duration_secs", end-start, "total_docs", s.search.TotalDocs()) if s.indexMetrics != nil { @@ -508,6 +536,54 @@ func (s *searchSupport) monitorIndexEvents(ctx context.Context) { } } +func (s *searchSupport) startPeriodicRebuild(ctx context.Context) { + ticker := time.NewTicker(s.rebuildInterval) + defer ticker.Stop() + + s.log.Info("starting periodic index rebuild", "interval", s.rebuildInterval) + + for { + select { + case <-ctx.Done(): + s.log.Info("stopping periodic index rebuild due to context cancellation") + return + case <-ticker.C: + s.log.Info("starting periodic index rebuild") + if err := s.rebuildDashboardIndexes(ctx); err != nil { + s.log.Error("error during periodic index rebuild", "error", err) + } else { + s.log.Info("periodic index rebuild completed successfully") + } + } + } +} + +func (s *searchSupport) rebuildDashboardIndexes(ctx context.Context) error { + ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"RebuildDashboardIndexes") + defer span.End() + + start := time.Now() + s.log.Info("rebuilding all search indexes") + + totalBatchesIndexed, err := s.buildIndexes(ctx, true) + if err != nil { + return fmt.Errorf("failed to rebuild dashboard indexes: %w", err) + } + + end := time.Now() + duration := end.Sub(start) + s.log.Info("completed rebuilding all dashboard search indexes", + "duration", duration, + "rebuilt_indexes", totalBatchesIndexed, + "total_docs", s.search.TotalDocs()) + + if s.indexMetrics != nil { + s.indexMetrics.IndexCreationTime.WithLabelValues().Observe(duration.Seconds()) + } + + return nil +} + func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { if s == nil || s.search == nil { return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)") @@ -770,3 +846,9 @@ func (s *searchSupport) getOrCreateIndexQueueProcessor(index ResourceIndex, nsr s.indexQueueProcessors[key] = indexQueueProcessor return indexQueueProcessor, nil } + +func (s *builderCache) clearNamespacedCache(key NamespacedResource) { + s.mu.Lock() + defer s.mu.Unlock() + s.ns.Remove(key) +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 2aa15a547ca..3e219956f07 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -158,6 +158,9 @@ type SearchOptions struct { // Channel to watch for index events (for testing) IndexEventsChan chan *IndexEvent + + // Interval for periodic index rebuilds (0 disables periodic rebuilds) + RebuildInterval time.Duration } type ResourceServerOptions struct { @@ -195,6 +198,8 @@ type ResourceServerOptions struct { storageMetrics *StorageMetrics IndexMetrics *BleveIndexMetrics + + MaxPageSizeBytes int } func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { @@ -219,6 +224,11 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { } } + if opts.MaxPageSizeBytes <= 0 { + // By default, we use 2MB for the page size. + opts.MaxPageSizeBytes = 1024 * 1024 * 2 + } + // Initialize the blob storage blobstore := opts.Blob.Backend if blobstore == nil { @@ -247,19 +257,20 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { // Make this cancelable ctx, cancel := context.WithCancel(context.Background()) s := &server{ - tracer: opts.Tracer, - log: logger, - backend: opts.Backend, - blob: blobstore, - diagnostics: opts.Diagnostics, - access: opts.AccessClient, - writeHooks: opts.WriteHooks, - lifecycle: opts.Lifecycle, - now: opts.Now, - ctx: ctx, - cancel: cancel, - storageMetrics: opts.storageMetrics, - indexMetrics: opts.IndexMetrics, + tracer: opts.Tracer, + log: logger, + backend: opts.Backend, + blob: blobstore, + diagnostics: opts.Diagnostics, + access: opts.AccessClient, + writeHooks: opts.WriteHooks, + lifecycle: opts.Lifecycle, + now: opts.Now, + ctx: ctx, + cancel: cancel, + storageMetrics: opts.storageMetrics, + indexMetrics: opts.IndexMetrics, + maxPageSizeBytes: opts.MaxPageSizeBytes, } if opts.Search.Resources != nil { @@ -304,6 +315,8 @@ type server struct { // init checking once sync.Once initErr error + + maxPageSizeBytes int } // Init implements ResourceServer. @@ -788,7 +801,7 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour if req.Limit < 1 { req.Limit = 50 // default max 50 items in a page } - maxPageBytes := 1024 * 1024 * 2 // 2mb/page + maxPageBytes := s.maxPageSizeBytes pageBytes := 0 rsp := &resourcepb.ListResponse{} diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 1dae7eebe02..4fd15ac2679 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -33,10 +33,11 @@ func NewSearchOptions(features featuremgmt.FeatureToggles, cfg *setting.Cfg, tra } return resource.SearchOptions{ - Backend: bleve, - Resources: docs, - WorkerThreads: cfg.IndexWorkers, - InitMinCount: cfg.IndexMinCount, + Backend: bleve, + Resources: docs, + WorkerThreads: cfg.IndexWorkers, + InitMinCount: cfg.IndexMinCount, + RebuildInterval: cfg.IndexRebuildInterval, }, nil } return resource.SearchOptions{}, nil diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index e5e1e845a9f..3d6b1d5f248 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -43,6 +43,12 @@ func NewResourceServer(db infraDB.DB, cfg *setting.Cfg, opts.Blob.URL = "file:///" + dir } + // This is mostly for testing, being able to influence when we paginate + // based on the page size during tests. + unifiedStorageCfg := cfg.SectionWithEnvOverrides("unified_storage") + maxPageSizeBytes := unifiedStorageCfg.Key("max_page_size_bytes") + opts.MaxPageSizeBytes = maxPageSizeBytes.MustInt(0) + eDB, err := dbimpl.ProvideResourceDB(db, cfg, tracer) if err != nil { return nil, err diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index d41ed71b9df..37d0501c355 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2088,10 +2088,8 @@ var expAlertmanagerConfigFromAPI = ` "name": "dingding_test", "type": "dingding", "disableResolveMessage": false, - "settings": { - "url": "http://CHANNEL_ADDR/dingding_recv/dingding_test" - }, - "secureFields": {} + "settings": {}, + "secureFields": {"url": true} } ] }, diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index a7422606b4d..1b92c7cd182 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -2400,3 +2400,90 @@ func runDashboardListTest(t *testing.T, ctx TestContext) { } }) } + +// TODO: this only works on mode0-3 right now. In modes 4/5, we need to start returning the connections endpoint +// from retrieving the panel count from search / indexing the dashboard library panels +func TestDashboardWithLibraryPanel(t *testing.T) { + dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3} + for _, dualWriterMode := range dualWriterModes { + t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{ + "unifiedStorageSearch", + "kubernetesClientDashboardsFolders", + }, + }) + ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + + // create the library element first + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Test Library Panel", + "model": map[string]interface{}{ + "type": "timeseries", + "title": "Test Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, libraryElementData) + data := libraryElementData["result"].(map[string]interface{}) + uid := data["uid"].(string) + require.NotEmpty(t, uid) + + // then reference the library element in the dashboard + dashboard := createDashboardObject(t, "Library Panel Test", "", 1) + dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Library Panel", + "type": "library-panel-ref", + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": "Test Library Panel", + }, + }, + } + + createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDash) + + // should have created a library panel connection + connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid) + connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, connectionsData) + connections := connectionsData["result"].([]interface{}) + require.Len(t, connections, 1) + }) + } +} + +func postHelper(t *testing.T, ctx *TestContext, path string, body interface{}, user apis.User) (map[string]interface{}, error) { + bodyJSON, err := json.Marshal(body) + require.NoError(t, err) + + resp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: user, + Method: http.MethodPost, + Path: path, + Body: bodyJSON, + ContentType: "application/json", + }, &struct{}{}) + + if resp.Response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to post: %s", resp.Response.Status) + } + + var result map[string]interface{} + err = json.Unmarshal(resp.Body, &result) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response JSON: %v", err) + } + + return result, nil +} diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index c918e1f1b64..c8975a601cb 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -177,6 +177,38 @@ func TestIntegrationFoldersApp(t *testing.T) { })) }) + // This is a general test for the unified storage list operation. We don't have a common test + // directory for now, so we (search and storage) keep it here as we own this part of the tests. + t.Run("make sure list works with continue tokens", func(t *testing.T) { + modes := []grafanarest.DualWriterMode{ + grafanarest.Mode1, + grafanarest.Mode2, + grafanarest.Mode3, + grafanarest.Mode4, + grafanarest.Mode5, + } + for _, mode := range modes { + t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) { + doListFoldersTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: mode, + }, + }, + // We set it to 1 here, so we always get forced pagination based on the response size. + UnifiedStorageMaxPageSizeBytes: 1, + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesClientDashboardsFolders, + featuremgmt.FlagNestedFolders, + }, + }), mode) + }) + } + }) + t.Run("when creating a folder it should trim leading and trailing spaces", func(t *testing.T) { doCreateEnsureTitleIsTrimmedTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, @@ -488,6 +520,65 @@ func doCreateCircularReferenceFolderTest(t *testing.T, helper *apis.K8sTestHelpe require.Equal(t, 400, create.Response.StatusCode) } +func doListFoldersTest(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest.DualWriterMode) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + foldersCount := 3 + for i := 0; i < foldersCount; i++ { + payload, err := json.Marshal(map[string]interface{}{ + "title": fmt.Sprintf("Test-%d", i), + "uid": fmt.Sprintf("uid-%d", i), + }) + require.NoError(t, err) + parentCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: payload, + }, &folder.Folder{}) + require.NotNil(t, parentCreate.Result) + require.Equal(t, http.StatusOK, parentCreate.Response.StatusCode) + } + fetchedFolders, fetchItemsPerCall := checkListRequest(t, 1, client) + require.Equal(t, []string{"uid-0", "uid-1", "uid-2"}, fetchedFolders) + require.Equal(t, []int{1, 1, 1}, fetchItemsPerCall[:3]) + + // Now let's see if the iterator also works when we are limited by the page size, which should be set + // to 1 byte for this test. We only need to check that if we test unified storage as the primary storage, + // as legacy doesn't have such a page size limit. + if mode == grafanarest.Mode3 || mode == grafanarest.Mode4 || mode == grafanarest.Mode5 { + t.Run("check page size iterator", func(t *testing.T) { + fetchedFolders, fetchItemsPerCall := checkListRequest(t, 3, client) + require.Equal(t, []string{"uid-0", "uid-1", "uid-2"}, fetchedFolders) + require.Equal(t, []int{1, 1, 1}, fetchItemsPerCall[:3]) + }) + } +} + +func checkListRequest(t *testing.T, limit int64, client *apis.K8sResourceClient) ([]string, []int) { + fetchedFolders := make([]string, 0, 3) + fetchItemsPerCall := make([]int, 0, 3) + continueToken := "" + for { + res, err := client.Resource.List(context.Background(), metav1.ListOptions{ + Limit: limit, + Continue: continueToken, + }) + require.NoError(t, err) + fetchItemsPerCall = append(fetchItemsPerCall, len(res.Items)) + for _, item := range res.Items { + fetchedFolders = append(fetchedFolders, item.GetName()) + } + continueToken = res.GetContinue() + if continueToken == "" { + break + } + } + return fetchedFolders, fetchItemsPerCall +} + func TestIntegrationFolderCreatePermissions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 22ec10b7073..63eaffb3583 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -152,7 +152,7 @@ func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { require.True(t, apierrors.IsNotFound(err)) var jobObj *unstructured.Unstructured - assert.EventuallyWithT(t, func(collect *assert.CollectT) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { result := helper.AdminREST.Post(). Namespace("default"). Resource("repositories"). @@ -169,10 +169,10 @@ func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { require.NoError(collect, err) var ok bool jobObj, ok = job.(*unstructured.Unstructured) - require.True(collect, ok, "expecting unstructured object, but got %T", job) + assert.True(collect, ok, "expecting unstructured object, but got %T", job) }, time.Second*10, time.Millisecond*10, "Expected to be able to start a sync job") - assert.EventuallyWithT(t, func(collect *assert.CollectT) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { //helper.TriggerJobProcessing(t) result, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "jobs", string(jobObj.GetUID())) @@ -190,9 +190,9 @@ func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job) require.NoError(t, err, "should convert to Job object") - require.Equal(t, provisioning.JobStateError, job.Status.State) - require.Equal(t, job.Status.Message, "completed with errors") - require.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") + assert.Equal(t, provisioning.JobStateError, job.Status.State) + assert.Equal(t, job.Status.Message, "completed with errors") + assert.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") }, time.Second*10, time.Millisecond*10, "Expected provisioning job to conclude with the status failed") _, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{}) @@ -285,10 +285,10 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) { err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}) require.NoError(t, err, "should delete values") - assert.EventuallyWithT(t, func(collect *assert.CollectT) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "can list values") - require.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted") + assert.NoError(t, err, "can list values") + assert.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted") }, time.Second*10, time.Millisecond*10, "Expected dashboards to be deleted") t.Run("github url cleanup", func(t *testing.T) { diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 0d6510df906..5310a1813ad 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -492,6 +492,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } } + if opts.UnifiedStorageMaxPageSizeBytes > 0 { + section, err := getOrCreateSection("unified_storage") + require.NoError(t, err) + _, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes)) + require.NoError(t, err) + } if opts.PermittedProvisioningPaths != "" { _, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths) require.NoError(t, err) @@ -556,6 +562,7 @@ type GrafanaOpts struct { QueryRetries int64 GrafanaComAPIURL string UnifiedStorageConfig map[string]setting.UnifiedStorageConfig + UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string GrafanaComSSOAPIToken string LicensePath string diff --git a/pkg/tsdb/loki/streaming.go b/pkg/tsdb/loki/streaming.go index 01dba69ed7c..5996161b554 100644 --- a/pkg/tsdb/loki/streaming.go +++ b/pkg/tsdb/loki/streaming.go @@ -13,9 +13,16 @@ import ( "github.com/gorilla/websocket" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + if !isFeatureEnabled(ctx, featuremgmt.FlagLokiExperimentalStreaming) { + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusPermissionDenied, + }, fmt.Errorf("streaming is not supported") + } + dsInfo, err := s.getDSInfo(ctx, req.PluginContext) if err != nil { return &backend.SubscribeStreamResponse{ diff --git a/pkg/tsdb/loki/streaming_test.go b/pkg/tsdb/loki/streaming_test.go new file mode 100644 index 00000000000..2492eb79dd7 --- /dev/null +++ b/pkg/tsdb/loki/streaming_test.go @@ -0,0 +1,61 @@ +package loki + +import ( + "context" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/require" +) + +func TestSubscribeStream(t *testing.T) { + // Create a service instance with required dependencies + service := &Service{ + im: datasource.NewInstanceManager(newInstanceSettings(httpclient.NewProvider())), + tracer: tracing.InitializeTracerForTest(), + logger: backend.NewLoggerWith("logger", "loki test"), + } + + // Create a test request + req := &backend.SubscribeStreamRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + ID: 1, + UID: "test", + Type: "loki", + URL: "http://localhost:3100", + }, + }, + Path: "tail/test", + Data: []byte(`{"expr": "test"}`), + } + + t.Run("when feature toggle is disabled", func(t *testing.T) { + // Create a context without the feature toggle enabled + ctx := context.Background() + + resp, err := service.SubscribeStream(ctx, req) + + require.Error(t, err) + require.Equal(t, "streaming is not supported", err.Error()) + require.Equal(t, backend.SubscribeStreamStatusPermissionDenied, resp.Status) + }) + + t.Run("when feature toggle is enabled", func(t *testing.T) { + // Create a context with the feature toggle enabled + cfg := backend.NewGrafanaCfg(map[string]string{ + featuretoggles.EnabledFeatures: featuremgmt.FlagLokiExperimentalStreaming, + }) + ctx := backend.WithGrafanaConfig(context.Background(), cfg) + + resp, err := service.SubscribeStream(ctx, req) + + require.NoError(t, err) + require.Equal(t, backend.SubscribeStreamStatusOK, resp.Status) + }) +} diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 82f427d23a5..6f4adb798a2 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -2818,6 +2818,7 @@ "format": "int64" }, "dashboardId": { + "description": "Deprecated: Use DashboardUID and OrgID instead", "type": "integer", "format": "int64" }, @@ -2899,6 +2900,9 @@ "type": "integer", "format": "int64" }, + "dashboardUID": { + "type": "string" + }, "id": { "type": "integer", "format": "int64" diff --git a/public/api-merged.json b/public/api-merged.json index d532fd82059..8ec57956afe 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -10148,7 +10148,7 @@ } }, "put": { - "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", + "description": "Omitting a key (`theme`, `homeDashboardUID`, `timezone`) will cause the current value to be replaced with the system default value.", "tags": [ "user_preferences" ], @@ -13025,6 +13025,7 @@ "format": "int64" }, "dashboardId": { + "description": "Deprecated: Use DashboardUID and OrgID instead", "type": "integer", "format": "int64" }, @@ -13106,6 +13107,9 @@ "type": "integer", "format": "int64" }, + "dashboardUID": { + "type": "string" + }, "id": { "type": "integer", "format": "int64" diff --git a/public/app/api/clients/advisor/baseAPI.ts b/public/app/api/clients/advisor/v0alpha1/baseAPI.ts similarity index 90% rename from public/app/api/clients/advisor/baseAPI.ts rename to public/app/api/clients/advisor/v0alpha1/baseAPI.ts index 2bac93ae545..2844f269cf0 100644 --- a/public/app/api/clients/advisor/baseAPI.ts +++ b/public/app/api/clients/advisor/v0alpha1/baseAPI.ts @@ -6,7 +6,7 @@ import { getAPIBaseURL } from 'app/api/utils'; export const BASE_URL = getAPIBaseURL('advisor.grafana.app', 'v0alpha1'); export const api = createApi({ - reducerPath: 'advisorAPI', + reducerPath: 'advisorAPIv0alpha1', baseQuery: createBaseQuery({ baseURL: BASE_URL, }), diff --git a/public/app/api/clients/advisor/endpoints.gen.ts b/public/app/api/clients/advisor/v0alpha1/endpoints.gen.ts similarity index 100% rename from public/app/api/clients/advisor/endpoints.gen.ts rename to public/app/api/clients/advisor/v0alpha1/endpoints.gen.ts diff --git a/public/app/api/clients/advisor/index.ts b/public/app/api/clients/advisor/v0alpha1/index.ts similarity index 93% rename from public/app/api/clients/advisor/index.ts rename to public/app/api/clients/advisor/v0alpha1/index.ts index 2cde4d01811..b659d3adb4c 100644 --- a/public/app/api/clients/advisor/index.ts +++ b/public/app/api/clients/advisor/v0alpha1/index.ts @@ -1,6 +1,6 @@ import { generatedAPI } from './endpoints.gen'; -export const advisorAPI = generatedAPI.enhanceEndpoints({ +export const advisorAPIv0alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { // Need to mutate the generated query to set the Content-Type header correctly updateCheck: (endpointDefinition) => { @@ -37,5 +37,5 @@ export const { useUpdateCheckMutation, useListCheckTypeQuery, useUpdateCheckTypeMutation, -} = advisorAPI; +} = advisorAPIv0alpha1; export { type Check, type CheckType } from './endpoints.gen'; // eslint-disable-line diff --git a/public/app/api/clients/folder/baseAPI.ts b/public/app/api/clients/folder/v1beta1/baseAPI.ts similarity index 91% rename from public/app/api/clients/folder/baseAPI.ts rename to public/app/api/clients/folder/v1beta1/baseAPI.ts index a51b1619eaa..3caca81a027 100644 --- a/public/app/api/clients/folder/baseAPI.ts +++ b/public/app/api/clients/folder/v1beta1/baseAPI.ts @@ -6,7 +6,7 @@ import { getAPIBaseURL } from 'app/api/utils'; export const BASE_URL = getAPIBaseURL('folder.grafana.app', 'v1beta1'); export const api = createApi({ - reducerPath: 'folderAPI', + reducerPath: 'folderAPIv1beta1', baseQuery: createBaseQuery({ baseURL: BASE_URL, }), diff --git a/public/app/api/clients/folder/endpoints.gen.ts b/public/app/api/clients/folder/v1beta1/endpoints.gen.ts similarity index 100% rename from public/app/api/clients/folder/endpoints.gen.ts rename to public/app/api/clients/folder/v1beta1/endpoints.gen.ts diff --git a/public/app/api/clients/folder/index.ts b/public/app/api/clients/folder/v1beta1/index.ts similarity index 56% rename from public/app/api/clients/folder/index.ts rename to public/app/api/clients/folder/v1beta1/index.ts index 7842aefb6c7..9c23e74f2b6 100644 --- a/public/app/api/clients/folder/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -1,8 +1,8 @@ import { generatedAPI } from './endpoints.gen'; -export const folderAPI = generatedAPI.enhanceEndpoints({}); +export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({}); -export const { useGetFolderQuery } = folderAPI; +export const { useGetFolderQuery } = folderAPIv1beta1; // eslint-disable-next-line no-barrel-files/no-barrel-files export { type Folder } from './endpoints.gen'; diff --git a/public/app/api/clients/iam/baseAPI.ts b/public/app/api/clients/iam/v0alpha1/baseAPI.ts similarity index 63% rename from public/app/api/clients/iam/baseAPI.ts rename to public/app/api/clients/iam/v0alpha1/baseAPI.ts index bb2d1e31850..ae67ceffb7d 100644 --- a/public/app/api/clients/iam/baseAPI.ts +++ b/public/app/api/clients/iam/v0alpha1/baseAPI.ts @@ -1,12 +1,12 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { createBaseQuery } from '../../createBaseQuery'; -import { getAPIBaseURL } from '../../utils'; +import { createBaseQuery } from 'app/api/createBaseQuery'; +import { getAPIBaseURL } from 'app/api/utils'; export const BASE_URL = getAPIBaseURL('iam.grafana.app', 'v0alpha1'); export const api = createApi({ baseQuery: createBaseQuery({ baseURL: BASE_URL }), - reducerPath: 'iamAPI', + reducerPath: 'iamAPIv0alpha1', endpoints: () => ({}), }); diff --git a/public/app/api/clients/iam/endpoints.gen.ts b/public/app/api/clients/iam/v0alpha1/endpoints.gen.ts similarity index 100% rename from public/app/api/clients/iam/endpoints.gen.ts rename to public/app/api/clients/iam/v0alpha1/endpoints.gen.ts diff --git a/public/app/api/clients/iam/index.ts b/public/app/api/clients/iam/v0alpha1/index.ts similarity index 62% rename from public/app/api/clients/iam/index.ts rename to public/app/api/clients/iam/v0alpha1/index.ts index af251160d2a..cfadbc79f0a 100644 --- a/public/app/api/clients/iam/index.ts +++ b/public/app/api/clients/iam/v0alpha1/index.ts @@ -1,5 +1,5 @@ import { generatedAPI } from './endpoints.gen'; -export const iamAPI = generatedAPI.enhanceEndpoints({}); +export const iamAPIv0alpha1 = generatedAPI.enhanceEndpoints({}); export const { useGetDisplayMappingQuery } = generatedAPI; diff --git a/public/app/api/clients/playlist/baseAPI.ts b/public/app/api/clients/playlist/v0alpha1/baseAPI.ts similarity index 90% rename from public/app/api/clients/playlist/baseAPI.ts rename to public/app/api/clients/playlist/v0alpha1/baseAPI.ts index a258fa0b9df..27cd1f3a325 100644 --- a/public/app/api/clients/playlist/baseAPI.ts +++ b/public/app/api/clients/playlist/v0alpha1/baseAPI.ts @@ -6,7 +6,7 @@ import { getAPIBaseURL } from 'app/api/utils'; export const BASE_URL = getAPIBaseURL('playlist.grafana.app', 'v0alpha1'); export const api = createApi({ - reducerPath: 'playlistAPI', + reducerPath: 'playlistAPIv0alpha1', baseQuery: createBaseQuery({ baseURL: BASE_URL, }), diff --git a/public/app/api/clients/playlist/endpoints.gen.ts b/public/app/api/clients/playlist/v0alpha1/endpoints.gen.ts similarity index 99% rename from public/app/api/clients/playlist/endpoints.gen.ts rename to public/app/api/clients/playlist/v0alpha1/endpoints.gen.ts index 0e771f9e7c8..7c5cd879b1d 100644 --- a/public/app/api/clients/playlist/endpoints.gen.ts +++ b/public/app/api/clients/playlist/v0alpha1/endpoints.gen.ts @@ -306,6 +306,7 @@ export type Playlist = { /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; metadata: ObjectMeta; + /** Spec is the spec of the Playlist */ spec: PlaylistSpec; status: PlaylistStatus; }; diff --git a/public/app/api/clients/playlist/index.ts b/public/app/api/clients/playlist/v0alpha1/index.ts similarity index 88% rename from public/app/api/clients/playlist/index.ts rename to public/app/api/clients/playlist/v0alpha1/index.ts index b8e70477de4..f42d9a318f2 100644 --- a/public/app/api/clients/playlist/index.ts +++ b/public/app/api/clients/playlist/v0alpha1/index.ts @@ -1,13 +1,13 @@ import { getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from '../../../core/actions'; -import { createSuccessNotification } from '../../../core/copy/appNotification'; -import { contextSrv } from '../../../core/services/context_srv'; -import { handleError } from '../../utils'; +import { notifyApp } from '../../../../core/actions'; +import { createSuccessNotification } from '../../../../core/copy/appNotification'; +import { contextSrv } from '../../../../core/services/context_srv'; +import { handleError } from '../../../utils'; import { generatedAPI, Playlist, PlaylistSpec } from './endpoints.gen'; -export const playlistAPI = generatedAPI.enhanceEndpoints({ +export const playlistAPIv0alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { getPlaylist: { transformResponse: async (response: Playlist) => { @@ -81,7 +81,7 @@ export const { useGetPlaylistQuery, useListPlaylistQuery, useReplacePlaylistMutation, -} = playlistAPI; +} = playlistAPIv0alpha1; // eslint-disable-next-line no-barrel-files/no-barrel-files export type { Playlist } from './endpoints.gen'; diff --git a/public/app/api/clients/provisioning/utils/getListParams.ts b/public/app/api/clients/provisioning/utils/getListParams.ts index eb4af0ab6e7..e4005ca866f 100644 --- a/public/app/api/clients/provisioning/utils/getListParams.ts +++ b/public/app/api/clients/provisioning/utils/getListParams.ts @@ -1,6 +1,6 @@ import { parseListOptionsSelector } from '../../../../features/apiserver/client'; import { ListOptions } from '../../../../features/apiserver/types'; -import { ListRepositoryApiArg } from '../endpoints.gen'; +import { ListRepositoryApiArg } from '../v0alpha1/endpoints.gen'; type ListParams = Omit & Pick; diff --git a/public/app/api/clients/provisioning/baseAPI.ts b/public/app/api/clients/provisioning/v0alpha1/baseAPI.ts similarity index 90% rename from public/app/api/clients/provisioning/baseAPI.ts rename to public/app/api/clients/provisioning/v0alpha1/baseAPI.ts index 9d3693bf8e3..b7709ed3b0e 100644 --- a/public/app/api/clients/provisioning/baseAPI.ts +++ b/public/app/api/clients/provisioning/v0alpha1/baseAPI.ts @@ -6,7 +6,7 @@ import { createBaseQuery } from 'app/api/createBaseQuery'; export const BASE_URL = `apis/provisioning.grafana.app/v0alpha1/namespaces/${config.namespace}`; export const api = createApi({ - reducerPath: 'provisioningAPI', + reducerPath: 'provisioningAPIv0alpha1', baseQuery: createBaseQuery({ baseURL: BASE_URL, }), diff --git a/public/app/api/clients/provisioning/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts similarity index 100% rename from public/app/api/clients/provisioning/endpoints.gen.ts rename to public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts diff --git a/public/app/api/clients/provisioning/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts similarity index 95% rename from public/app/api/clients/provisioning/index.ts rename to public/app/api/clients/provisioning/v0alpha1/index.ts index 99a5ca42f23..986915cebbe 100644 --- a/public/app/api/clients/provisioning/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -1,12 +1,13 @@ import { t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; -import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification'; + +import { notifyApp } from '../../../../core/actions'; +import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; +import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded'; import { generatedAPI, JobSpec, JobStatus, RepositorySpec, RepositoryStatus, ErrorDetails } from './endpoints.gen'; -import { createOnCacheEntryAdded } from './utils/createOnCacheEntryAdded'; -export const provisioningAPI = generatedAPI.enhanceEndpoints({ +export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { listJob: { // Do not include 'watch' in the first query, so we can get the initial list of jobs diff --git a/public/app/core/components/TimelineChart/timeline.test.ts b/public/app/core/components/TimelineChart/timeline.test.ts new file mode 100644 index 00000000000..1fd72e9bec5 --- /dev/null +++ b/public/app/core/components/TimelineChart/timeline.test.ts @@ -0,0 +1,211 @@ +import uPlot from 'uplot'; + +import { getDefaultTimeRange, createTheme } from '@grafana/data'; +import { VisibilityMode } from '@grafana/schema'; + +import { getConfig, TimelineCoreOptions } from './timeline'; +import { TimelineMode } from './utils'; + +jest.mock('uplot'); + +describe('StateTimeline uPlot integration', () => { + const buildTestCoreOptions = (opts: Partial = {}): TimelineCoreOptions => ({ + mode: TimelineMode.Changes, + numSeries: 1, + theme: createTheme(), + showValue: VisibilityMode.Always, + isDiscrete: jest.fn(() => true), + hasMappedNull: jest.fn(() => false), + hasMappedNaN: jest.fn(() => false), + getValueColor: jest.fn(() => '#fff'), + label: jest.fn(() => 'foo'), + getTimeRange: jest.fn(() => getDefaultTimeRange()), + getFieldConfig: jest.fn(() => ({})), + hoverMulti: false, + ...opts, + }); + + const buildMockUplotInstance = ( + data: Array> = [ + [0, 0, 0], + [0, 1, 2], + ] + ) => + ({ + ctx: { + save: jest.fn(), + restore: jest.fn(), + rect: jest.fn(), + clip: jest.fn(), + font: '', + fill: jest.fn(), + fillStyle: '', + fillText: jest.fn(), + measureText: jest.fn(() => ({ width: 0 })), + beginPath: jest.fn(), + }, + root: document.createElement('div'), + bbox: { left: 0, top: 0, width: 100, height: 100 }, + data, + cursor: { left: 0, top: 0 }, + series: [{}], + scales: {}, + opts: {}, + pxRatio: 1, + posToVal: jest.fn(), + valToPos: jest.fn(), + }) as unknown as uPlot; + + const callOrientCallback = (mockUplot: uPlot) => { + const orientCallback = jest.mocked(uPlot.orient).mock.calls[jest.mocked(uPlot.orient).mock.calls.length - 1][2]; + const methods = { + moveTo: jest.fn(() => {}), + lineTo: jest.fn(() => {}), + rect: jest.fn(() => {}), + arc: jest.fn(() => {}), + bezierCurveTo: jest.fn(() => {}), + }; + + orientCallback( + mockUplot.series[0], + mockUplot.data[0] as number[], + mockUplot.data[1] as number[], + mockUplot.scales.x, + mockUplot.scales.y, + jest.fn(() => 1), + jest.fn(() => 1), + 0, + 0, + 100, + 100, + methods.moveTo, + methods.lineTo, + methods.rect, + methods.arc, + methods.bezierCurveTo + ); + + return methods; + }; + + describe('#drawPoints', () => { + it('returns a `drawPoints` method when a `formatValue` function is provided', () => { + const config = getConfig(buildTestCoreOptions({ formatValue: () => 'foo' })); + expect(typeof config.drawPoints).toBe('function'); + }); + + it('returns false for `drawPoints` when no `formatValue` function is provided', () => { + const config = getConfig(buildTestCoreOptions()); + expect(config.drawPoints).toBe(false); + }); + + it('returns false for `drawPoints` if the visibility mode is `never`', () => { + const config = getConfig(buildTestCoreOptions({ formatValue: () => 'foo', showValue: VisibilityMode.Never })); + expect(config.drawPoints).toBe(false); + }); + + it('returns a function for `drawPoints` if the conditions are met', () => { + const { drawPoints } = getConfig(buildTestCoreOptions({ formatValue: () => 'foo' })); + if (!drawPoints) { + throw new Error('drawPoints is not defined'); + } + const mockUplot = buildMockUplotInstance(); + expect(drawPoints(mockUplot, 1, 2, 3, null)).toBe(false); + expect(uPlot.orient).toHaveBeenCalledWith(mockUplot, 1, expect.any(Function)); + }); + + describe('#drawPaths', () => { + describe('null and NaN values', () => { + // these tests are attempting to determine whether `shouldDrawYVal` is returning false + // and preventing a draw for a given value. this is being done by checking the number of + // calls to `rect` by the `orient` callback created by a `drawPaths` call. + + it('should draw boxes for null values when hasMappedNull returns true and isDiscrete returns true', () => { + const { drawClear, drawPaths } = getConfig( + buildTestCoreOptions({ + hasMappedNull: jest.fn(() => true), + isDiscrete: jest.fn(() => true), + formatValue: () => 'foo', + }) + ); + const mockUplot = buildMockUplotInstance([[0], [null]]); + + drawClear(mockUplot); + drawPaths(mockUplot, 1, 0, 1); + + const { rect } = callOrientCallback(mockUplot); + expect(rect).toHaveBeenCalledTimes(2); + }); + + it('should not draw boxes for null values when hasMappedNull returns false and isDiscrete returns true', () => { + const { drawClear, drawPaths } = getConfig( + buildTestCoreOptions({ + hasMappedNull: jest.fn(() => false), + isDiscrete: jest.fn(() => true), + formatValue: () => 'foo', + }) + ); + const mockUplot = buildMockUplotInstance([[0], [null]]); + + drawClear(mockUplot); + drawPaths(mockUplot, 1, 0, 1); + + const { rect } = callOrientCallback(mockUplot); + expect(rect).toHaveBeenCalledTimes(1); + }); + + it('should draw boxes for NaN values when hasMappedNaN returns true and isDiscrete returns true', () => { + const { drawClear, drawPaths } = getConfig( + buildTestCoreOptions({ + hasMappedNaN: jest.fn(() => true), + isDiscrete: jest.fn(() => true), + formatValue: () => 'foo', + }) + ); + const mockUplot = buildMockUplotInstance([[0], [NaN]]); + + drawClear(mockUplot); + drawPaths(mockUplot, 1, 0, 1); + + const { rect } = callOrientCallback(mockUplot); + expect(rect).toHaveBeenCalledTimes(2); + }); + + it('should not draw boxes for NaN values when hasMappedNaN returns false and isDiscrete returns true', () => { + const { drawClear, drawPaths } = getConfig( + buildTestCoreOptions({ + hasMappedNaN: jest.fn(() => false), + isDiscrete: jest.fn(() => true), + formatValue: () => 'foo', + }) + ); + const mockUplot = buildMockUplotInstance([[0], [NaN]]); + + drawClear(mockUplot); + drawPaths(mockUplot, 1, 0, 1); + + const { rect } = callOrientCallback(mockUplot); + expect(rect).toHaveBeenCalledTimes(1); + }); + + it('should not draw boxes for NaN or null values when isDiscrete returns false', () => { + const { drawClear, drawPaths } = getConfig( + buildTestCoreOptions({ + hasMappedNaN: jest.fn(() => true), + hasMappedNull: jest.fn(() => true), + isDiscrete: jest.fn(() => false), + formatValue: () => 'foo', + }) + ); + const mockUplot = buildMockUplotInstance([[0], [NaN, null]]); + + drawClear(mockUplot); + drawPaths(mockUplot, 1, 0, 1); + + const { rect } = callOrientCallback(mockUplot); + expect(rect).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index 8fa0a8faef1..302437593e9 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -47,6 +47,7 @@ export interface TimelineCoreOptions { mergeValues?: boolean; isDiscrete: (seriesIdx: number) => boolean; hasMappedNull: (seriesIdx: number) => boolean; + hasMappedNaN: (seriesIdx: number) => boolean; getValueColor: (seriesIdx: number, value: unknown) => string; label: (seriesIdx: number) => string; getTimeRange: () => TimeRange; @@ -64,6 +65,7 @@ export function getConfig(opts: TimelineCoreOptions) { numSeries, isDiscrete, hasMappedNull, + hasMappedNaN, rowHeight = 0, colWidth = 0, showValue, @@ -196,9 +198,9 @@ export function getConfig(opts: TimelineCoreOptions) { sidx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => { let strokeWidth = round((series.width || 0) * uPlot.pxRatio); - - let discrete = isDiscrete(sidx); - let mappedNull = discrete && hasMappedNull(sidx); + const discrete = isDiscrete(sidx); + const mappedNull = discrete && hasMappedNull(sidx); + const mappedNaN = discrete && hasMappedNaN(sidx); u.ctx.save(); rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); @@ -209,7 +211,10 @@ export function getConfig(opts: TimelineCoreOptions) { for (let ix = 0; ix < dataY.length; ix++) { let yVal = dataY[ix]; - if (yVal != null || mappedNull) { + const shouldDrawY = + !!yVal || yVal === 0 || (yVal === null && mappedNull) || (Number.isNaN(yVal) && mappedNaN); + + if (shouldDrawY) { let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff)); let nextIx = ix; @@ -252,8 +257,10 @@ export function getConfig(opts: TimelineCoreOptions) { for (let ix = idx0; ix <= idx1; ix++) { let yVal = dataY[ix]; + const shouldDrawY = + !!yVal || yVal === 0 || (yVal === null && mappedNull) || (Number.isNaN(yVal) && mappedNaN); - if (yVal != null || mappedNull) { + if (shouldDrawY) { // TODO: all xPos can be pre-computed once for all series in aligned set let left = valToPosX(dataX[ix], scaleX, xDim, xOff); @@ -306,14 +313,18 @@ export function getConfig(opts: TimelineCoreOptions) { sidx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => { let strokeWidth = round((series.width || 0) * uPlot.pxRatio); - - let discrete = isDiscrete(sidx); - let mappedNull = discrete && hasMappedNull(sidx); - let y = round(valToPosY(ySplits[sidx - 1], scaleY, yDim, yOff)); + const discrete = isDiscrete(sidx); + const mappedNull = discrete && hasMappedNull(sidx); + const mappedNaN = discrete && hasMappedNaN(sidx); + for (let ix = 0; ix < dataY.length; ix++) { - if (dataY[ix] != null || mappedNull) { + const yVal = dataY[ix]; + const shouldDrawY = + !!yVal || yVal === 0 || (yVal == null && mappedNull) || (Number.isNaN(yVal) && mappedNaN); + + if (shouldDrawY) { const boxRect = boxRectsBySeries[sidx - 1][ix]; if (!boxRect || boxRect.x >= xDim) { diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index a3c181ef42b..6dc82194d0c 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -8,8 +8,10 @@ import { DataFrame, fieldMatchers, FieldMatcherID, + Field, + SpecialValueMatch, } from '@grafana/data'; -import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema'; +import { LegendDisplayMode, MappingType, VizLegendOptions } from '@grafana/schema'; import { preparePlotFrame } from '../GraphNG/utils'; @@ -17,6 +19,7 @@ import { findNextStateIndex, fmtDuration, getThresholdItems, + hasSpecialMappedValue, makeFramePerSeries, prepareTimelineFields, prepareTimelineLegendItems, @@ -504,3 +507,47 @@ describe('duration', () => { expect(result).toEqual(expected); }); }); + +describe('hasSpecialMappedValue', () => { + const makeField = (mappingsType: MappingType | SpecialValueMatch, optionsMatch: MappingType | SpecialValueMatch) => + ({ + name: 'Field', + type: FieldType.frame, + config: { + mappings: [ + { + type: mappingsType, + options: { match: optionsMatch, result: {} }, + }, + ], + }, + values: [], + }) as Field; + + it.each([ + [[MappingType.SpecialValue, SpecialValueMatch.Null], SpecialValueMatch.Null, true, 'should match Null with Null'], + [[MappingType.SpecialValue, SpecialValueMatch.NaN], SpecialValueMatch.NaN, true, 'should match NaN with NaN'], + [ + [MappingType.SpecialValue, SpecialValueMatch.NullAndNaN], + SpecialValueMatch.NullAndNaN, + true, + 'should match Null and NaN with Null and NaN', + ], + [ + [MappingType.SpecialValue, SpecialValueMatch.NullAndNaN], + SpecialValueMatch.Empty, + false, + 'should NOT match Null and NaN with Empty', + ], + [ + [MappingType.ValueToText, SpecialValueMatch.Null], + SpecialValueMatch.Null, + false, + 'should NOT match non-special value', + ], + ])('%s', ([mappingsType, optionsMatch], valueMatch, expected, _) => { + const field = makeField(mappingsType, optionsMatch); + + expect(hasSpecialMappedValue(field, valueMatch)).toEqual(expected); + }); +}); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index a9af674a839..c22dfc574e3 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -20,6 +20,7 @@ import { ThresholdsConfig, applyNullInsertThreshold, nullToValue, + SpecialValueMatch, } from '@grafana/data'; import { maybeSortFrame, NULL_RETAIN } from '@grafana/data/internal'; import { @@ -72,6 +73,12 @@ const defaultConfig: PanelFieldConfig = { fillOpacity: 80, }; +/** Checks if a mapped value of the specified type exists for the given field */ +export const hasSpecialMappedValue = (field: Field, match: SpecialValueMatch): boolean => + field.config.mappings?.some( + (mapping: ValueMapping): boolean => mapping.type === MappingType.SpecialValue && mapping.options.match === match + ) || false; + export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ frame, theme, @@ -94,15 +101,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( const mode = field.config?.color?.mode; return !(mode && field.display && mode.startsWith('continuous-')); }; - - const hasMappedNull = (field: Field) => { - return ( - field.config.mappings?.some( - (mapping) => mapping.type === MappingType.SpecialValue && mapping.options.match === 'null' - ) || false - ); - }; - const getValueColorFn = (seriesIdx: number, value: unknown) => { const field = frame.fields[seriesIdx]; @@ -121,7 +119,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( mode: mode!, numSeries: frame.fields.length - 1, isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]), - hasMappedNull: (seriesIdx) => hasMappedNull(frame.fields[seriesIdx]), + hasMappedNull: (seriesIdx) => + hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.Null) || + hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NullAndNaN), + hasMappedNaN: (seriesIdx) => + hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NaN) || + hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NullAndNaN), mergeValues, rowHeight: rowHeight, colWidth: colWidth, @@ -664,7 +667,7 @@ export function findNextStateIndex(field: Field, datapointIdx: number) { * This function calculates with 30 days month and 365 days year. * adapted from https://gist.github.com/remino/1563878 * @param milliSeconds The duration in milliseconds - * @returns A formated string of the duration + * @returns A formatted string of the duration */ export function fmtDuration(milliSeconds: number): string { if (milliSeconds < 0 || Number.isNaN(milliSeconds)) { diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 4a545d2c350..d28d2f9caba 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -1,6 +1,7 @@ import { ReducersMapObject } from '@reduxjs/toolkit'; import { AnyAction, combineReducers } from 'redux'; +import { alertingAPIv0alpha1 } from '@grafana/alerting/unstable'; import sharedReducers from 'app/core/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; @@ -26,11 +27,11 @@ import teamsReducers from 'app/features/teams/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; -import { advisorAPI } from '../../api/clients/advisor'; -import { folderAPI } from '../../api/clients/folder'; -import { iamAPI } from '../../api/clients/iam'; -import { playlistAPI } from '../../api/clients/playlist'; -import { provisioningAPI } from '../../api/clients/provisioning'; +import { advisorAPIv0alpha1 } from '../../api/clients/advisor/v0alpha1'; +import { folderAPIv1beta1 } from '../../api/clients/folder/v1beta1'; +import { iamAPIv0alpha1 } from '../../api/clients/iam/v0alpha1'; +import { playlistAPIv0alpha1 } from '../../api/clients/playlist/v0alpha1'; +import { provisioningAPIv0alpha1 } from '../../api/clients/provisioning/v0alpha1'; import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; import { userPreferencesAPI } from '../../features/preferences/api'; import { cleanUpAction } from '../actions/cleanUp'; @@ -60,15 +61,16 @@ const rootReducers = { ...authConfigReducers, plugins: pluginsReducer, [alertingApi.reducerPath]: alertingApi.reducer, + [alertingAPIv0alpha1.reducerPath]: alertingAPIv0alpha1.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, [cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer, - [iamAPI.reducerPath]: iamAPI.reducer, - [playlistAPI.reducerPath]: playlistAPI.reducer, + [iamAPIv0alpha1.reducerPath]: iamAPIv0alpha1.reducer, + [playlistAPIv0alpha1.reducerPath]: playlistAPIv0alpha1.reducer, [userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer, - [provisioningAPI.reducerPath]: provisioningAPI.reducer, - [folderAPI.reducerPath]: folderAPI.reducer, - [advisorAPI.reducerPath]: advisorAPI.reducer, + [provisioningAPIv0alpha1.reducerPath]: provisioningAPIv0alpha1.reducer, + [folderAPIv1beta1.reducerPath]: folderAPIv1beta1.reducer, + [advisorAPIv0alpha1.reducerPath]: advisorAPIv0alpha1.reducer, // PLOP_INJECT_REDUCER // Used by the API client generator }; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 3ff866f92c9..37f70eb6b9a 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -45,6 +45,7 @@ export class KeybindingSrv { // Chromeless pages like login and signup page don't get any global bindings if (!route.chromeless) { this.bind('?', this.showHelpModal); + this.bind('g h', this.goToHome); this.bind('g d', this.goToDashboards); this.bind('g e', this.goToExplore); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 5f5bb7728c3..0d579e37e53 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -14,7 +14,7 @@ import { GrafanaAlertingConfiguration, Matcher, } from '../../../../plugins/datasource/alertmanager/types'; -import { NotifierDTO } from '../../../../types'; +import { NotificationChannelOption, NotifierDTO } from '../../../../types'; import { withPerformanceLogging } from '../Analytics'; import { matcherToMatcherField } from '../utils/alertmanager'; import { @@ -106,6 +106,25 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ grafanaNotifiers: build.query({ query: () => ({ url: '/api/alert-notifiers' }), + transformResponse: (response: NotifierDTO[]) => { + const populateSecureFieldKey = ( + option: NotificationChannelOption, + prefix: string + ): NotificationChannelOption => ({ + ...option, + secureFieldKey: option.secure && !option.secureFieldKey ? `${prefix}${option.propertyName}` : undefined, + subformOptions: option.subformOptions?.map((suboption) => + populateSecureFieldKey(suboption, `${prefix}${option.propertyName}.`) + ), + }); + + return response.map((notifier) => ({ + ...notifier, + options: notifier.options.map((option) => { + return populateSecureFieldKey(option, ''); + }), + })); + }, }), // this endpoint requires administrator privileges diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx index 1b3ff9d002e..2fe703a1347 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx @@ -43,6 +43,13 @@ describe('filterRulerRulesConfig', () => { namespace: 'synthetic_monitoring', }, }, + { + alert: 'Alert7', + expr: 'test == 0', + labels: { + namespace: 'integrations-test', + }, + }, ], }, ], @@ -196,7 +203,7 @@ describe('filterRulerRulesConfig', () => { expect(someRulesAreSkipped).toBe(false); }); - it('should filter out synthetics rules', () => { + it('should filter out synthetics rules and rules from integrations', () => { const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig); expect(filteredConfig).toEqual({ diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx index 2aaf5797653..4f2c364859a 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx @@ -49,6 +49,24 @@ const AlertSomeRulesSkipped = () => { ); }; +const WarningForImportingRulesManagedByIntegrations = () => { + return ( + + + + Rules managed by integrations or plugins should not be imported to Grafana-managed rules. + + + + ); +}; + const emptyObject = {}; export const ConfirmConversionModal = ({ importPayload, isOpen, onDismiss }: ModalProps) => { @@ -217,6 +235,7 @@ export const ConfirmConversionModal = ({ importPayload, isOpen, onDismiss }: Mod {!isEmpty(rulesThatMightBeOverwritten) && ( )} + {someRulesAreSkipped && } The following alert rules will be imported: @@ -232,8 +251,7 @@ export const ConfirmConversionModal = ({ importPayload, isOpen, onDismiss }: Mod /** * Filter the ruler rules config to be imported. It filters the rules by namespace and group name. - * It also filters out the rules that have the '__grafana_origin' label, and rules from synthetics that have the - * 'namespace: synthetic_monitoring' label. + * It also filters out the rules that are managed by integrations or plugins. * Precondition: these rules are cloud rules. * @param rulerRulesConfig - The ruler rules config to be imported * @param namespace - The namespace to filter the rules by @@ -262,7 +280,7 @@ export function filterRulerRulesConfig( }) .map((group) => { const filteredRules = group.rules.filter((rule) => { - const shouldSkip = shouldSkipRule(rule); + const shouldSkip = isRuleManagedByExternalSystem(rule); if (shouldSkip) { someRulesAreSkipped = true; return false; @@ -286,18 +304,25 @@ export function filterRulerRulesConfig( } /* -This function is used to check if the rule should be skipped. +This function is used to check if the rule is managed by external system. It checks if the rule has the '__grafana_origin' label, and if the rule is from synthetics. -If the rule has the '__grafana_origin' label, it is skipped. -If the rule is from synthetics, it is skipped. +These are the conditions for a rule to be managed by external system: +- If the rule has the '__grafana_origin' label +- If the rule is from synthetics +- If the rule is from integrations */ -function shouldSkipRule(rule: RulerRuleDTO): boolean { +function isRuleManagedByExternalSystem(rule: RulerRuleDTO): boolean { // check if the rule has the '__grafana_origin' label const hasGrafanaOriginLabel = isPluginProvidedRule(rule); if (hasGrafanaOriginLabel) { return true; } - // check if the rule is from synthetics + // check if the rule is from intergrations by checking if the namespace starts with 'integrations-' + const isIntegration = rule.labels?.namespace?.startsWith('integrations-'); + if (isIntegration) { + return true; + } + // check if the rule is from synthetics by checking if the namespace is 'synthetic_monitoring' const hasSyntheticsLabels = rule.labels?.namespace === 'synthetic_monitoring'; if (!hasSyntheticsLabels) { @@ -342,7 +367,6 @@ const getStyles = () => ({ function TargetFolderNotEmptyWarning({ targetFolderRules }: { targetFolderRules: RulerRulesConfigDTO }) { const [showTargetRules, toggleShowTargetRules] = useToggle(false); - return ( diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx index 0648a964d4a..e402c4f8875 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -18,7 +18,7 @@ export interface Props { selectedChannelOptions: NotificationChannelOption[]; onResetSecureField: (key: string) => void; - onDeleteSubform?: (propertyName: string) => void; + onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void; errors?: FieldErrors; /** * The path for the integration in the array of integrations. @@ -66,7 +66,7 @@ export function ChannelOptions({ return null; } - if (secureFields && secureFields[option.propertyName]) { + if (secureFields && secureFields[option.secureFieldKey ?? option.propertyName]) { return ( ({ > onResetSecureField(option.propertyName)} + onReset={() => onResetSecureField(option.secureFieldKey ?? option.propertyName)} isConfigured /> @@ -85,7 +85,7 @@ export function ChannelOptions({ const error: FieldError | DeepMap | undefined = ( (option.secure ? errors?.secureFields : errors?.settings) as DeepMap | undefined - )?.[option.propertyName]; + )?.[option.secureFieldKey ?? option.propertyName]; const defaultValue = defaultValues?.settings?.[option.propertyName]; @@ -122,6 +122,7 @@ const determineRequired = ( return option.required ? 'Required' : false; } + // TODO: This doesn't work with nested secureFields. const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]); if (dependentOn) { @@ -140,5 +141,6 @@ const determineReadOnly = ( return false; } + // TODO: This doesn't work with nested secureFields. return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]); }; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index e06d26bc4ed..7e47f08b5a2 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -2,11 +2,12 @@ import { css } from '@emotion/css'; import { sortBy } from 'lodash'; import * as React from 'react'; import { useEffect, useMemo } from 'react'; -import { Controller, FieldErrors, useFormContext, useWatch } from 'react-hook-form'; +import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { NotificationChannelOption } from 'app/types'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { @@ -99,9 +100,6 @@ export function ChannelSubForm({ return () => subscription.unsubscribe(); }, [selectedType, initialValues, setValue, settingsFieldPath, typeFieldPath, watch]); - // const [_secureFields, setSecureFields] = useState>(secureFields ?? {}); - const formSecureFields = useWatch({ control, name: `${channelFieldPath}.secureFields` }); - const onResetSecureField = (key: string) => { // formSecureFields might not be up to date if this function is called multiple times in a row const currentSecureFields = getValues(`${channelFieldPath}.secureFields`); @@ -110,12 +108,29 @@ export function ChannelSubForm({ } }; - const onDeleteSubform = (propertyName: string) => { - const relatedSecureFields = Object.keys(formSecureFields).filter((key) => key.startsWith(propertyName)); + const findSecureFieldsRecursively = (options: NotificationChannelOption[]): string[] => { + const secureFields: string[] = []; + options?.forEach((option) => { + if (option.secure && option.secureFieldKey) { + secureFields.push(option.secureFieldKey); + } + if (option.subformOptions) { + secureFields.push(...findSecureFieldsRecursively(option.subformOptions)); + } + }); + return secureFields; + }; + + const onDeleteSubform = (settingsPath: string, option: NotificationChannelOption) => { + // Get all subform options with secure=true recursively. + const relatedSecureFields = findSecureFieldsRecursively(option.subformOptions ?? []); relatedSecureFields.forEach((key) => { onResetSecureField(key); }); - setValue(`${channelFieldPath}.settings.${propertyName}`, undefined); + const fieldPath = settingsPath.startsWith(`${channelFieldPath}.settings.`) + ? settingsPath.slice(`${channelFieldPath}.settings.`.length) + : settingsPath; + setValue(`${settingsFieldPath}.${fieldPath}`, undefined); }; const typeOptions = useMemo( diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx index f8962f5c174..937c64f4afb 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx @@ -65,12 +65,26 @@ const ui = { webhook: { url: byRole('textbox', { name: /^URL/ }), tlsConfig: { - header: byRole('heading', { name: /TLS/ }), - caCertificate: byRole('textbox', { name: /^CA certificate/ }), - clientCert: byRole('textbox', { name: /^Client certificate/ }), - clientKey: byRole('textbox', { name: /^Client key/ }), + container: byTestId('items.0.settings.tlsConfig.container'), + caCertificate: byRole('textbox', { name: /^CA Certificate/ }), + clientCert: byRole('textbox', { name: /^Client Certificate/ }), + clientKey: byRole('textbox', { name: /^Client Key/ }), deleteButton: byTestId('items.0.settings.tlsConfig.delete-button'), }, + httpConfig: { + container: byTestId('items.0.settings.http_config.container'), + oauth2: { + container: byTestId('items.0.settings.http_config.oauth2.container'), + clientSecret: byRole('textbox', { name: /^Client Secret/ }), + tls_config: { + container: byTestId('items.0.settings.http_config.oauth2.tls_config.container'), + caCertificate: byRole('textbox', { name: /^CA Certificate/ }), + clientCert: byRole('textbox', { name: /^Client Certificate/ }), + clientKey: byRole('textbox', { name: /^Client Key/ }), + deleteButton: byTestId('items.0.settings.http_config.oauth2.tls_config.delete-button'), + }, + }, + }, optionalSettings: byRole('button', { name: /optional webhook settings/i }), }, }; @@ -416,6 +430,81 @@ describe('GrafanaReceiverForm', () => { }); describe('Webhook contact point', () => { + it('should mark secure fields as configured when values exist', async () => { + const contactPointName = 'webhook-test'; + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [ + integrationFactory + .webhook() + .params({ + settings: { + url: 'http://example.com', + tlsConfig: { + insecureSkipVerify: false, + }, + http_config: { + oauth2: { + client_id: 'client-id', + token_url: 'http://example.com/oauth2/token', + scopes: ['scope1', 'scope2'], + endpoint_params: { + param1: 'value1', + param2: 'value2', + }, + tls_config: { + insecureSkipVerify: false, + }, + proxy_config: { + proxy_url: 'http://example.com/proxy', + no_proxy: 'example.com', + proxy_from_environment: true, + proxy_connect_header: { + 'X-Custom-Header': 'custom-value', + }, + }, + }, + }, + }, + secureFields: { + 'tlsConfig.caCertificate': true, + 'tlsConfig.clientCertificate': true, + 'tlsConfig.clientKey': true, + 'http_config.oauth2.client_secret': true, + 'http_config.oauth2.tls_config.caCertificate': true, + 'http_config.oauth2.tls_config.clientCertificate': true, + 'http_config.oauth2.tls_config.clientKey': true, + }, + }) + .build(), + ]) + .build({ id: 'webhook-id', name: contactPointName, metadata: { name: contactPointName } }); + + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + await waitFor(() => expect(ui.webhook.optionalSettings.query()).toBeInTheDocument()); + await user.click(ui.webhook.optionalSettings.get()); + + const tlsContainer = await ui.webhook.tlsConfig.container.find(); + const caCertField = ui.webhook.tlsConfig.caCertificate.get(tlsContainer); + const clientCertField = ui.webhook.tlsConfig.clientCert.get(tlsContainer); + const clientKeyField = ui.webhook.tlsConfig.clientKey.get(tlsContainer); + expect(caCertField).toHaveValue('configured'); + expect(clientCertField).toHaveValue('configured'); + expect(clientKeyField).toHaveValue('configured'); + + // Deeply nested secure fields. + const oauth2Container = await ui.webhook.httpConfig.oauth2.container.find(); + const clientSecretField = ui.webhook.httpConfig.oauth2.clientSecret.get(oauth2Container); + const oauthCaCertField = ui.webhook.httpConfig.oauth2.tls_config.caCertificate.get(oauth2Container); + const oauthClientCertField = ui.webhook.httpConfig.oauth2.tls_config.clientCert.get(oauth2Container); + const oauthClientKeyField = ui.webhook.httpConfig.oauth2.tls_config.clientKey.get(oauth2Container); + expect(clientSecretField).toHaveValue('configured'); + expect(oauthCaCertField).toHaveValue('configured'); + expect(oauthClientCertField).toHaveValue('configured'); + expect(oauthClientKeyField).toHaveValue('configured'); + }); + it('should properly remove TLS config when deleted', async () => { const contactPointName = 'webhook-test'; const contactPoint = alertingFactory.alertmanager.grafana.contactPoint @@ -431,6 +520,35 @@ describe('GrafanaReceiverForm', () => { clientKey: 'client-key', insecureSkipVerify: false, }, + http_config: { + oauth2: { + client_id: 'client-id', + token_url: 'http://example.com/oauth2/token', + scopes: ['scope1', 'scope2'], + endpoint_params: { + param1: 'value1', + param2: 'value2', + }, + tls_config: { + // This tls config has existing values via secureFields, delete should remove this correctly as well. + insecureSkipVerify: false, + }, + proxy_config: { + proxy_url: 'http://example.com/proxy', + no_proxy: 'example.com', + proxy_from_environment: true, + proxy_connect_header: { + 'X-Custom-Header': 'custom-value', + }, + }, + }, + }, + }, + secureFields: { + 'http_config.oauth2.client_secret': true, + 'http_config.oauth2.tls_config.caCertificate': true, + 'http_config.oauth2.tls_config.clientCertificate': true, + 'http_config.oauth2.tls_config.clientKey': true, }, }) .build(), @@ -448,9 +566,14 @@ describe('GrafanaReceiverForm', () => { // Find and click the delete button next to TLS config await user.click(ui.webhook.optionalSettings.get()); - expect(await ui.webhook.tlsConfig.header.find(undefined)).toBeInTheDocument(); + // Delete new tlsConfig values. + expect(await ui.webhook.tlsConfig.container.find()).toBeInTheDocument(); await user.click(await ui.webhook.tlsConfig.deleteButton.find()); + // Delete existing oauth2 values. + expect(await ui.webhook.httpConfig.oauth2.tls_config.container.find()).toBeInTheDocument(); + await user.click(await ui.webhook.httpConfig.oauth2.tls_config.deleteButton.find()); + await user.click(ui.saveButton.get()); const requests = await capture; @@ -467,6 +590,12 @@ describe('GrafanaReceiverForm', () => { expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientCert'); expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientKey'); + // Verify that OAuth2 TLS config is not present in the settings + expect(integrationPayload.settings).not.toHaveProperty('http_config.oauth2.tls_config'); + expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.caCertificate'); + expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.clientCert'); + expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.clientKey'); + expect(postRequestBody).toMatchSnapshot(); }); }); diff --git a/public/app/features/alerting/unified/components/receivers/form/__snapshots__/GrafanaReceiverForm.test.tsx.snap b/public/app/features/alerting/unified/components/receivers/form/__snapshots__/GrafanaReceiverForm.test.tsx.snap index dd2fba64876..84de2030021 100644 --- a/public/app/features/alerting/unified/components/receivers/form/__snapshots__/GrafanaReceiverForm.test.tsx.snap +++ b/public/app/features/alerting/unified/components/receivers/form/__snapshots__/GrafanaReceiverForm.test.tsx.snap @@ -41,8 +41,32 @@ exports[`GrafanaReceiverForm Webhook contact point should properly remove TLS co { "disableResolveMessage": false, "name": "webhook-test", - "secureFields": {}, + "secureFields": { + "http_config.oauth2.client_secret": true, + }, "settings": { + "http_config": { + "oauth2": { + "client_id": "client-id", + "endpoint_params": { + "param1": "value1", + "param2": "value2", + }, + "proxy_config": { + "no_proxy": "example.com", + "proxy_connect_header": { + "X-Custom-Header": "custom-value", + }, + "proxy_from_environment": true, + "proxy_url": "http://example.com/proxy", + }, + "scopes": [ + "scope1", + "scope2", + ], + "token_url": "http://example.com/oauth2/token", + }, + }, "url": "http://example.com", }, "type": "webhook", diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx index 15a3c62f46e..62db9294dc3 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx @@ -26,21 +26,18 @@ interface Props { defaultValue: any; option: NotificationChannelOption; getOptionMeta?: (option: NotificationChannelOption) => OptionMeta; - // this is defined if the option is rendered inside a subform - parentOption?: NotificationChannelOption; invalid?: boolean; pathPrefix: string; error?: FieldError | DeepMap; readOnly?: boolean; customValidator?: (value: string) => boolean | string | Promise; onResetSecureField?: (propertyName: string) => void; - onDeleteSubform?: (propertyName: string) => void; + onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void; secureFields: NotificationChannelSecureFields; } export const OptionField: FC = ({ option, - parentOption, invalid, pathPrefix, error, @@ -94,7 +91,6 @@ export const OptionField: FC = ({ invalid={invalid} pathPrefix={pathPrefix} readOnly={readOnly} - parentOption={parentOption} customValidator={customValidator} onResetSecureField={onResetSecureField} secureFields={secureFields} @@ -113,7 +109,6 @@ const OptionInput: FC = ({ customValidator, onResetSecureField, secureFields = {}, - parentOption, getOptionMeta, }) => { const styles = useStyles2(getStyles); @@ -122,9 +117,9 @@ const OptionInput: FC = ({ const optionMeta = getOptionMeta?.(option); const name = `${pathPrefix}${option.propertyName}`; - const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : option.propertyName; - const isEncryptedInput = secureFields?.[nestedKey]; + const secureFieldKey = option.secure && option.secureFieldKey ? option.secureFieldKey : ''; + const isEncryptedInput = secureFieldKey && secureFields?.[secureFieldKey]; // workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506 useEffect( @@ -162,7 +157,7 @@ const OptionInput: FC = ({ onSelectTemplate={onSelectTemplate} > {isEncryptedInput ? ( - onResetSecureField?.(nestedKey)} isConfigured /> + onResetSecureField?.(secureFieldKey)} isConfigured /> ) : ( = ({ onSelectTemplate={onSelectTemplate} > {isEncryptedInput ? ( - onResetSecureField?.(nestedKey)} isConfigured /> + onResetSecureField?.(secureFieldKey)} isConfigured /> ) : (