From 56106064b1092ed83e3168c6d5dcc757fb41284a Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:27:35 -0600 Subject: [PATCH 01/31] Dashboards: Disable saving while title is validating (#111518) * disable saving while title is validating * don't show loading icon in the save button while validating * fix e2e --- e2e-playwright/utils/dashboard-helpers.ts | 7 ++++++- .../dashboard-scene/saving/SaveDashboardAsForm.tsx | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/e2e-playwright/utils/dashboard-helpers.ts b/e2e-playwright/utils/dashboard-helpers.ts index ff73bfe919f..d37d3dee25e 100644 --- a/e2e-playwright/utils/dashboard-helpers.ts +++ b/e2e-playwright/utils/dashboard-helpers.ts @@ -21,7 +21,12 @@ export async function addDashboard(page: Page, title?: string): Promise // Click save const saveAsButton = page.getByTestId('data-testid Save dashboard drawer button'); - await saveAsButton.click(); + // Ensure button is ready and click using the method that works with React + // Doing simply saveAsButton.click doesn't work, even with force: true and when button is enabled + // It stopped working when https://github.com/grafana/grafana/pull/111518 introduced proper title validation + // This should be a an ok alternative since we are checking that the button is enabled first + await expect(saveAsButton).toBeEnabled(); + await saveAsButton.evaluate((btn: HTMLElement) => btn.click()); // Wait for success notification await expect(page.getByText('Dashboard saved')).toBeVisible(); diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index cc437b74486..aa5c53aa1fa 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -43,7 +43,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { }, }); - const { errors, isValid } = formState; + const { errors, isValid, validatingFields } = formState; const formValues = watch(); const { state, onSaveDashboard } = useSaveDashboard(false); @@ -85,7 +85,16 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { const saveButton = (overwrite: boolean) => { const showSaveButton = !isValid && hasFolderChanged ? true : isValid; - return ; + const isTitleValidating = !!validatingFields.title; + + return ( + + ); }; function renderFooter(error?: Error) { const formValuesMatchContentSent = From 217990ded9d513a9157002f28ae2892f08f0c77b Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:29:05 -0400 Subject: [PATCH 02/31] Docs: Update saved queries feature availability details (#111639) --- .../panels-visualizations/query-transform-data/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/panels-visualizations/query-transform-data/_index.md b/docs/sources/panels-visualizations/query-transform-data/_index.md index cf823e24bd4..10d810409c0 100644 --- a/docs/sources/panels-visualizations/query-transform-data/_index.md +++ b/docs/sources/panels-visualizations/query-transform-data/_index.md @@ -121,7 +121,7 @@ query_result(max_over_time([${__range_s}s]) != ) {{< admonition type="note" >}} Saved queries is currently in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. -This feature is only available on Grafana Enterprise and Grafana Cloud. +This feature is only available on Grafana Enterprise and Grafana Cloud. It will gradually roll out to all Grafana Cloud users with no action required. To try out this feature on Grafana Enterprise, enable the `queryLibrary` feature toggle. {{< /admonition >}} You can save queries that you've created so they can be reused by you and others in your organization. From 24c10b4fb9d1f5848ca4e4b312f8e4e614842b35 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Thu, 25 Sep 2025 16:09:54 -0400 Subject: [PATCH 03/31] Alerting: Remove usages of ReceiverType (#111508) * remove usages of ReceiverType --- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- go.work.sum | 6 +- .../ngalert/api/forking_alertmanager.go | 6 +- .../api/tooling/definitions/alertmanager.go | 62 +-------- .../ngalert/notifier/alertmanager_config.go | 30 ++--- pkg/services/ngalert/notifier/crypto.go | 126 +++++++++--------- 9 files changed, 93 insertions(+), 149 deletions(-) diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 8455f3e0f0b..64c5a52c1a8 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -204,7 +204,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876 // indirect + github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 // indirect github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index e6336bf9235..b93048ed57a 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -721,8 +721,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20250915130141-a8ee25091876 h1:BzoGpzARwRCNOHcqQdYPAFp2LS1pqnkLWhIuDdq1zho= -github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A= github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E= diff --git a/go.mod b/go.mod index 5a66ee070ad..5a986999c43 100644 --- a/go.mod +++ b/go.mod @@ -86,7 +86,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.2 // @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-20250915130141-a8ee25091876 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index 53c44fc95a0..b444408d771 100644 --- a/go.sum +++ b/go.sum @@ -1585,8 +1585,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-20250915130141-a8ee25091876 h1:BzoGpzARwRCNOHcqQdYPAFp2LS1pqnkLWhIuDdq1zho= -github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A= github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E= diff --git a/go.work.sum b/go.work.sum index d9a860898d1..e1eb4ee0513 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1040,6 +1040,10 @@ github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 h1:3imTbxFpZSVI6I github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q= github.com/grafana/alerting v0.0.0-20250709204613-c5c6f9c1653d/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q= github.com/grafana/alerting v0.0.0-20250911172908-2b26ef8f17eb/go.mod h1:XWqj/rlsy4OV/E9XNNyFn+a7U4GNsSugPb2rDBj9+58= +github.com/grafana/alerting v0.0.0-20250923203439-adb598e7d509 h1:8JMtYCClxrxRXsF5jc64GTURZFHJHFK/kzC7joRNTtI= +github.com/grafana/alerting v0.0.0-20250923203439-adb598e7d509/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0= +github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8= github.com/grafana/authlib v0.0.0-20250909101823-1b466dbd19a1/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A= github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM= @@ -1074,8 +1078,6 @@ github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+ github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= -github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo= -github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index 96e295fc68f..349fba06936 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -206,8 +206,10 @@ func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel if err != nil { return errorToResponse(err) } - if !body.AlertmanagerConfig.ReceiverType().Can(apimodels.AlertmanagerReceiverType) { - return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.AlertmanagerBackend, body.AlertmanagerConfig.ReceiverType().String())) + for _, p := range body.AlertmanagerConfig.Receivers { + if p.HasGrafanaIntegrations() { + return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.AlertmanagerBackend, apimodels.GrafanaBackend.String())) + } } return s.RoutePostAlertingConfig(ctx, body) } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 0c034514319..5c33ad658fe 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -267,7 +267,6 @@ type ( ObjectMatchers = definition.ObjectMatchers PostableApiReceiver = definition.PostableApiReceiver PostableGrafanaReceivers = definition.PostableGrafanaReceivers - ReceiverType = definition.ReceiverType ) type MergeResult definition.MergeResult @@ -295,9 +294,6 @@ func (m MergeResult) LogContext() []any { } const ( - GrafanaReceiverType = definition.GrafanaReceiverType - AlertmanagerReceiverType = definition.AlertmanagerReceiverType - errInvalidExtraConfigurationMsg = "Invalid Alertmanager configuration: {{.Public.Error}}" ) @@ -870,12 +866,8 @@ func (c *PostableUserConfig) validate() error { func (c *PostableUserConfig) GetGrafanaReceiverMap() map[string]*PostableGrafanaReceiver { UIDs := make(map[string]*PostableGrafanaReceiver) for _, r := range c.AlertmanagerConfig.Receivers { - switch r.Type() { - case GrafanaReceiverType: - for _, gr := range r.GrafanaManagedReceivers { - UIDs[gr.UID] = gr - } - default: + for _, gr := range r.GrafanaManagedReceivers { + UIDs[gr.UID] = gr } } return UIDs @@ -975,12 +967,8 @@ func (c *GettableUserConfig) MarshalJSON() ([]byte, error) { func (c *GettableUserConfig) GetGrafanaReceiverMap() map[string]*GettableGrafanaReceiver { UIDs := make(map[string]*GettableGrafanaReceiver) for _, r := range c.AlertmanagerConfig.Receivers { - switch r.Type() { - case GrafanaReceiverType: - for _, gr := range r.GrafanaManagedReceivers { - UIDs[gr.UID] = gr - } - default: + for _, gr := range r.GrafanaManagedReceivers { + UIDs[gr.UID] = gr } } return UIDs @@ -1097,47 +1085,7 @@ type GettableApiReceiver struct { func (r *GettableApiReceiver) UnmarshalJSON(b []byte) error { type plain GettableApiReceiver - if err := json.Unmarshal(b, (*plain)(r)); err != nil { - return err - } - - hasGrafanaReceivers := len(r.GrafanaManagedReceivers) > 0 - - if hasGrafanaReceivers { - if len(r.EmailConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager EmailConfigs & Grafana receivers together") - } - if len(r.PagerdutyConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager PagerdutyConfigs & Grafana receivers together") - } - if len(r.SlackConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager SlackConfigs & Grafana receivers together") - } - if len(r.WebhookConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager WebhookConfigs & Grafana receivers together") - } - if len(r.OpsGenieConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager OpsGenieConfigs & Grafana receivers together") - } - if len(r.WechatConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager WechatConfigs & Grafana receivers together") - } - if len(r.PushoverConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager PushoverConfigs & Grafana receivers together") - } - if len(r.VictorOpsConfigs) > 0 { - return fmt.Errorf("cannot have both Alertmanager VictorOpsConfigs & Grafana receivers together") - } - } - - return nil -} - -func (r *GettableApiReceiver) Type() ReceiverType { - if len(r.GrafanaManagedReceivers) > 0 { - return GrafanaReceiverType - } - return AlertmanagerReceiverType + return json.Unmarshal(b, (*plain)(r)) } func (r *GettableApiReceiver) GetName() string { diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 3d4ebba01f9..25a24cb5441 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -452,26 +452,22 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error { seenUIDs := make(map[string]struct{}) // encrypt secure settings for storing them in DB for _, r := range c { - switch r.Type() { - case definitions.GrafanaReceiverType: - for _, gr := range r.GrafanaManagedReceivers { - if gr.UID == "" { - retries := 5 - for i := 0; i < retries; i++ { - gen := util.GenerateShortUID() - _, ok := seenUIDs[gen] - if !ok { - gr.UID = gen - break - } - } - if gr.UID == "" { - return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) + for _, gr := range r.GrafanaManagedReceivers { + if gr.UID == "" { + retries := 5 + for i := 0; i < retries; i++ { + gen := util.GenerateShortUID() + _, ok := seenUIDs[gen] + if !ok { + gr.UID = gen + break } } - seenUIDs[gr.UID] = struct{}{} + if gr.UID == "" { + return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) + } } - default: + seenUIDs[gr.UID] = struct{}{} } } return nil diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index a44ff34af88..c147fa4cec2 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -87,81 +87,77 @@ func EncryptReceiverConfigSettings(c []*definitions.PostableApiReceiver, encrypt func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt definitions.EncryptFn, encryptExisting bool) error { // encrypt secure settings for storing them in DB for _, r := range c { - switch r.Type() { - case definitions.GrafanaReceiverType: - for _, gr := range r.GrafanaManagedReceivers { - if encryptExisting { - for k, v := range gr.SecureSettings { - encryptedData, err := encrypt(context.Background(), []byte(v)) + for _, gr := range r.GrafanaManagedReceivers { + if encryptExisting { + for k, v := range gr.SecureSettings { + encryptedData, err := encrypt(context.Background(), []byte(v)) + if err != nil { + return fmt.Errorf("failed to encrypt secure settings: %w", err) + } + gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) + } + } + + if len(gr.Settings) > 0 { + // We need to parse the settings to check for secret keys. If we find any, we encrypt them and + // store them in SecureSettings. This can happen from incorrect configuration or when an integration + // definition is updated to make a field secure. + settings := make(map[string]any) + if err := json.Unmarshal(gr.Settings, &settings); err != nil { + return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err) + } + + secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type, channels_config.V1) + if err != nil { + return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err) + } + + secureSettings := gr.SecureSettings + if secureSettings == nil { + secureSettings = make(map[string]string) + } + + settingsChanged := false + secureSettingsChanged := false + for _, secretKey := range secretKeys { + settingsValue, ok := settings[secretKey] + if !ok { + continue + } + + // Secrets should not be stored in settings regardless. + delete(settings, secretKey) + settingsChanged = true + + // If the secret is already encrypted, we don't need to encrypt it again. + if _, ok := secureSettings[secretKey]; ok { + continue + } + + if strVal, isString := settingsValue.(string); isString { + encrypted, err := encrypt(context.Background(), []byte(strVal)) if err != nil { return fmt.Errorf("failed to encrypt secure settings: %w", err) } - gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) + secureSettings[secretKey] = base64.StdEncoding.EncodeToString(encrypted) + secureSettingsChanged = true } } - if len(gr.Settings) > 0 { - // We need to parse the settings to check for secret keys. If we find any, we encrypt them and - // store them in SecureSettings. This can happen from incorrect configuration or when an integration - // definition is updated to make a field secure. - settings := make(map[string]any) - if err := json.Unmarshal(gr.Settings, &settings); err != nil { - return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err) - } - - secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type, channels_config.V1) + // Defensive checks to limit the risk of unintentional edge case changes in this legacy API. + if settingsChanged { + // If we removed any secret keys from settings, we need to save the updated settings. + jsonBytes, err := json.Marshal(settings) if err != nil { - return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err) - } - - secureSettings := gr.SecureSettings - if secureSettings == nil { - secureSettings = make(map[string]string) - } - - settingsChanged := false - secureSettingsChanged := false - for _, secretKey := range secretKeys { - settingsValue, ok := settings[secretKey] - if !ok { - continue - } - - // Secrets should not be stored in settings regardless. - delete(settings, secretKey) - settingsChanged = true - - // If the secret is already encrypted, we don't need to encrypt it again. - if _, ok := secureSettings[secretKey]; ok { - continue - } - - if strVal, isString := settingsValue.(string); isString { - encrypted, err := encrypt(context.Background(), []byte(strVal)) - if err != nil { - return fmt.Errorf("failed to encrypt secure settings: %w", err) - } - secureSettings[secretKey] = base64.StdEncoding.EncodeToString(encrypted) - secureSettingsChanged = true - } - } - - // Defensive checks to limit the risk of unintentional edge case changes in this legacy API. - if settingsChanged { - // If we removed any secret keys from settings, we need to save the updated settings. - jsonBytes, err := json.Marshal(settings) - if err != nil { - return err - } - gr.Settings = jsonBytes - } - if secureSettingsChanged { - // If we added any secure settings, we need to save the updated secure settings. - gr.SecureSettings = secureSettings + return err } + gr.Settings = jsonBytes + } + if secureSettingsChanged { + // If we added any secure settings, we need to save the updated secure settings. + gr.SecureSettings = secureSettings } } - default: } } return nil From 2f5aacdb5d8aee03768b983f859f09120b0abe59 Mon Sep 17 00:00:00 2001 From: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:34:49 -0600 Subject: [PATCH 04/31] Update IAM Folder Reconciler local dev environment to include tools for profiling the operator. (#111641) Update local dev setup to allow for profiling of the operator --- apps/iam/Tiltfile | 22 ++-- apps/iam/local/yamls/alloy.yaml | 122 +++++++++++++++++ apps/iam/local/yamls/grafana.yaml | 197 ++++++++++++++++++++++++++++ apps/iam/local/yamls/jaeger.yaml | 101 ++++++++++++++ apps/iam/local/yamls/operator.yaml | 118 ++++++++++++----- apps/iam/local/yamls/pyroscope.yaml | 45 +++++++ apps/iam/local/yamls/zanzana.yaml | 11 +- 7 files changed, 564 insertions(+), 52 deletions(-) create mode 100644 apps/iam/local/yamls/alloy.yaml create mode 100644 apps/iam/local/yamls/grafana.yaml create mode 100644 apps/iam/local/yamls/jaeger.yaml create mode 100644 apps/iam/local/yamls/pyroscope.yaml diff --git a/apps/iam/Tiltfile b/apps/iam/Tiltfile index f72dd50552a..ab7c530e4b6 100644 --- a/apps/iam/Tiltfile +++ b/apps/iam/Tiltfile @@ -3,14 +3,16 @@ # https://docs.tilt.dev/api.html#api.version_settings version_settings(constraint='>=0.22.2') -custom_build( - 'grafana-iam-operator', - command='docker build -t $EXPECTED_REF -f cmd/operator/Dockerfile .', - deps=[ - 'cmd/operator', - 'pkg', - ], - disable_push=True, -) - k8s_yaml([filename for filename in listdir('local/yamls') if filename.lower().endswith(('.yaml', '.yml'))]) + +# Port forward Grafana to localhost:3000 +k8s_resource('grafana', port_forwards=['3000:3000']) + +# Port forward Jaeger UI to localhost:16686 +k8s_resource('jaeger-agent', port_forwards=['16686:16686']) + +# Port forward Pyroscope UI to localhost:4040 +k8s_resource('pyroscope', port_forwards=['4040:4040']) + +# Port forward Alloy UI to localhost:12345 +k8s_resource('alloy', port_forwards=['12345:12345']) diff --git a/apps/iam/local/yamls/alloy.yaml b/apps/iam/local/yamls/alloy.yaml new file mode 100644 index 00000000000..b968b854dea --- /dev/null +++ b/apps/iam/local/yamls/alloy.yaml @@ -0,0 +1,122 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: alloy-config + namespace: default +data: + config.alloy: | + // Pyroscope configuration to receive profiles + pyroscope.write "default" { + endpoint { + url = "http://pyroscope.default.svc.cluster.local:4040" + } + } + + // Scrape CPU profiles from the IAM operator + pyroscope.scrape "iam_operator" { + targets = [ + { + "__address__" = "iam-folder-reconciler.default.svc.cluster.local:6060", + "service_name" = "iam-folder-reconciler", + }, + ] + forward_to = [pyroscope.write.default.receiver] + + job_name = "iam-operator" + scrape_interval = "30s" + scrape_timeout = "25s" + + profiling_config { + profile.process_cpu { + enabled = true + path = "/debug/pprof/profile" + delta = false + } + + profile.godeltaprof_memory { + enabled = true + path = "/debug/pprof/delta_heap" + } + + profile.memory { + enabled = true + path = "/debug/pprof/heap" + delta = false + } + + profile.godeltaprof_mutex { + enabled = true + path = "/debug/pprof/delta_mutex" + } + + profile.godeltaprof_block { + enabled = true + path = "/debug/pprof/delta_block" + } + + profile.goroutine { + enabled = true + path = "/debug/pprof/goroutine" + delta = false + } + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: alloy + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + name: alloy + template: + metadata: + labels: + name: alloy + spec: + containers: + - name: alloy + image: grafana/alloy:v1.10.0 + args: + - run + - /etc/alloy/config.alloy + - --storage.path=/var/lib/alloy/data + - --server.http.listen-addr=0.0.0.0:12345 + - --stability.level=experimental + ports: + - containerPort: 12345 + name: http + volumeMounts: + - name: config + mountPath: /etc/alloy + - name: storage + mountPath: /var/lib/alloy/data + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + volumes: + - name: config + configMap: + name: alloy-config + - name: storage + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: alloy + namespace: default +spec: + selector: + name: alloy + ports: + - name: http + port: 12345 + targetPort: 12345 diff --git a/apps/iam/local/yamls/grafana.yaml b/apps/iam/local/yamls/grafana.yaml new file mode 100644 index 00000000000..b5aa0f1627e --- /dev/null +++ b/apps/iam/local/yamls/grafana.yaml @@ -0,0 +1,197 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasources + namespace: default +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: Jaeger + type: jaeger + uid: local-jaeger + access: proxy + url: http://jaeger-agent.jaeger.svc.cluster.local:16686 + editable: true + isDefault: true + jsonData: + tracesToLogsV2: + datasourceUid: 'local-loki' + customQuery: false + filterByTraceID: true + filterBySpanID: true + nodeGraph: + enabled: true + search: + hide: false + spanBar: + type: 'Duration' + - name: Pyroscope + type: grafana-pyroscope-datasource + uid: local-pyroscope + access: proxy + url: http://pyroscope.default.svc.cluster.local:4040 + editable: true + isDefault: false +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-config + namespace: default +data: + grafana.ini: | + app_mode = development + + [paths] + data = /var/lib/grafana + logs = /var/log/grafana + plugins = /var/lib/grafana/plugins + provisioning = /etc/grafana/provisioning + + [server] + http_port = 3000 + + [database] + type = sqlite3 + path = grafana.db + + [session] + provider = file + provider_config = sessions + + [analytics] + reporting_enabled = false + check_for_updates = false + + [security] + admin_user = admin + admin_password = admin + disable_gravatar = true + + [snapshots] + external_enabled = false + + [users] + allow_sign_up = false + allow_org_create = false + auto_assign_org = true + auto_assign_org_role = Viewer + + [plugin.grafana-pyroscope-app] + app_enabled = true + + [auth.anonymous] + enabled = true + org_name = Main Org. + org_role = Editor + + [log] + mode = console + level = info + + [explore] + enabled = true + + [feature_toggles] + enable = traceqlStreaming,correlations,traceToMetrics +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: default + labels: + app: grafana +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + securityContext: + fsGroup: 472 + supplementalGroups: + - 0 + containers: + - name: grafana + image: grafana/grafana:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + name: http-grafana + protocol: TCP + env: + - name: GF_PATHS_CONFIG + value: /etc/grafana/grafana.ini + - name: GF_PATHS_PROVISIONING + value: /etc/grafana/provisioning + resources: + requests: + cpu: 250m + memory: 750Mi + limits: + cpu: 500m + memory: 1Gi + volumeMounts: + - mountPath: /var/lib/grafana + name: grafana-storage + - mountPath: /etc/grafana + name: grafana-config + - mountPath: /etc/grafana/provisioning/datasources + name: grafana-datasources + readinessProbe: + failureThreshold: 3 + httpGet: + path: /api/health + port: 3000 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 30 + successThreshold: 1 + timeoutSeconds: 2 + livenessProbe: + failureThreshold: 3 + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 1 + httpGet: + path: /api/health + port: 3000 + timeoutSeconds: 1 + volumes: + - name: grafana-storage + emptyDir: {} + - name: grafana-config + configMap: + name: grafana-config + - name: grafana-datasources + configMap: + name: grafana-datasources + items: + - key: datasources.yaml + path: datasources.yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: default + labels: + app: grafana +spec: + type: NodePort + ports: + - port: 3000 + protocol: TCP + targetPort: http-grafana + nodePort: 30000 + name: grafana-http + selector: + app: grafana +--- diff --git a/apps/iam/local/yamls/jaeger.yaml b/apps/iam/local/yamls/jaeger.yaml new file mode 100644 index 00000000000..341e4b0b42e --- /dev/null +++ b/apps/iam/local/yamls/jaeger.yaml @@ -0,0 +1,101 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: jaeger +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jaeger-agent + namespace: jaeger + labels: + app: jaeger-agent +spec: + replicas: 1 + selector: + matchLabels: + app: jaeger-agent + template: + metadata: + labels: + app: jaeger-agent + spec: + containers: + - name: jaeger-agent + image: jaegertracing/all-in-one:latest + ports: + - containerPort: 6831 + protocol: UDP + name: agent-udp + - containerPort: 16686 + name: ui + - containerPort: 14268 + name: collector + - containerPort: 4317 + name: otlp-grpc + - containerPort: 4318 + name: otlp-http + - containerPort: 5778 + name: sampling + env: + - name: MEMORY_MAX_TRACES + value: "100000" + - name: SPAN_STORAGE_TYPE + value: "badger" + - name: COLLECTOR_OTLP_ENABLED + value: "true" + - name: LOG_LEVEL + value: "debug" + resources: + limits: + memory: "1Gi" + cpu: "500m" + requests: + memory: "512Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: / + port: 16686 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 16686 + initialDelaySeconds: 30 + periodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: jaeger-agent + namespace: jaeger + labels: + app: jaeger-agent +spec: + selector: + app: jaeger-agent + ports: + - name: agent-udp + port: 6831 + targetPort: 6831 + protocol: UDP + - name: ui + port: 16686 + targetPort: 16686 + - name: collector + port: 14268 + targetPort: 14268 + - name: otlp-grpc + port: 4317 + targetPort: 4317 + - name: otlp-http + port: 4318 + targetPort: 4318 + - name: sampling + port: 5778 + targetPort: 5778 + type: ClusterIP +--- diff --git a/apps/iam/local/yamls/operator.yaml b/apps/iam/local/yamls/operator.yaml index 4ef08bc7d19..574b9ad5b7d 100644 --- a/apps/iam/local/yamls/operator.yaml +++ b/apps/iam/local/yamls/operator.yaml @@ -4,10 +4,43 @@ metadata: name: operator namespace: default --- +apiVersion: v1 +kind: ConfigMap +metadata: + name: operator-config + namespace: default +data: + operator.ini: | + app_mode = development + target = operator + ensure_default_org_and_user = false + skip_migrations = true + + + [grpc_client_authentication] + token_exchange_url = http://host.docker.internal:8080/v1/sign-access-token + token_namespace = * + + [operator] + folder_app_url = https://host.docker.internal:6446 + max_concurrent_workers = 20 + tls_ca_file = + tls_insecure = true + zanzana_url = zanzana.default.svc.cluster.local:50051 + + [tracing.opentelemetry] + custom_attributes = namespace:grafana-iam + sampler_param = 1 + sampler_type = remote + sampling_server_url = http://jaeger-agent.jaeger.svc.cluster.local.:5778/sampling + + [tracing.opentelemetry.otlp] + address = jaeger-agent.jaeger.svc.cluster.local.:4317 +--- apiVersion: apps/v1 kind: Deployment metadata: - name: iam-app-operator + name: iam-folder-reconciler namespace: default spec: minReadySeconds: 10 @@ -15,44 +48,63 @@ spec: revisionHistoryLimit: 10 selector: matchLabels: - name: iam-app-operator + name: iam-folder-reconciler template: metadata: labels: - name: iam-app-operator + name: iam-folder-reconciler spec: - serviceAccount: operator containers: - - image: grafana-iam-operator - imagePullPolicy: IfNotPresent - name: iam-app-operator - command: ["/bin/sh"] - args: - - -c - - exec /usr/bin/operator + - command: + - grafana-server + - target + - --config=/etc/grafana-config/operator.ini + - --homepath=/usr/share/grafana env: - name: KUBE_FEATURE_WatchListClient - value: "false" - - name: ZANZANA_ADDR - value: zanzana.default.svc.cluster.local:50051 - - name: FOLDER_APP_URL - value: https://host.docker.internal:6446 - - name: FOLDER_APP_NAMESPACE - value: grafana-folder - - name: TOKEN_EXCHANGE_URL - value: http://host.docker.internal:8080/v1/sign-access-token - - name: AUTH_TOKEN + value: "true" + - name: GF_DEFAULT_TARGET + value: operator + - name: GF_OPERATOR_NAME + value: iam-folder-reconciler + - name: GF_DIAGNOSTICS_PROFILING_ENABLED + value: "true" + - name: GF_DIAGNOSTICS_PROFILING_ADDR + value: "0.0.0.0" + - name: GF_DIAGNOSTICS_PROFILING_PORT + value: "6060" + - name: GF_GRPC_CLIENT_AUTHENTICATION_TOKEN valueFrom: secretKeyRef: - name: iam-app-operator-secrets - key: auth-token - - name: ZANZANA_TOKEN - valueFrom: - secretKeyRef: - name: iam-app-operator-secrets - key: zanzana-token - - name: FOLDER_RECONCILER_NAMESPACE - valueFrom: - secretKeyRef: - name: iam-app-operator-secrets - key: folder-reconciler-namespace + name: iam-operator-secrets + key: grpc_auth_token + image: grafana/grafana-dev:12.3.0-17863745596 + imagePullPolicy: IfNotPresent + name: iam-folder-reconciler + volumeMounts: + - name: operator-config + mountPath: /etc/grafana-config + serviceAccount: operator + volumes: + - configMap: + name: operator-config + name: operator-config +--- +apiVersion: v1 +kind: Service +metadata: + name: iam-folder-reconciler + namespace: default + labels: + name: iam-folder-reconciler +spec: + ports: + - name: iam-folder-reconciler-http-metrics + port: 8080 + targetPort: 8080 + - name: iam-folder-reconciler-pprof + port: 6060 + targetPort: 6060 + selector: + name: iam-folder-reconciler +--- diff --git a/apps/iam/local/yamls/pyroscope.yaml b/apps/iam/local/yamls/pyroscope.yaml new file mode 100644 index 00000000000..1a8d088a11f --- /dev/null +++ b/apps/iam/local/yamls/pyroscope.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pyroscope + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + name: pyroscope + template: + metadata: + labels: + name: pyroscope + spec: + containers: + - name: pyroscope + image: grafana/pyroscope:1.14.0 + ports: + - containerPort: 4040 + name: http + env: + - name: PYROSCOPE_LOG_LEVEL + value: "info" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: [] +--- +apiVersion: v1 +kind: Service +metadata: + name: pyroscope + namespace: default +spec: + selector: + name: pyroscope + ports: + - name: http + port: 4040 + targetPort: 4040 diff --git a/apps/iam/local/yamls/zanzana.yaml b/apps/iam/local/yamls/zanzana.yaml index 5a306717468..b54c06b709d 100644 --- a/apps/iam/local/yamls/zanzana.yaml +++ b/apps/iam/local/yamls/zanzana.yaml @@ -16,7 +16,7 @@ spec: spec: containers: - name: postgres - image: postgres:15.7 + image: postgres:15 env: - name: POSTGRES_USER value: grafana @@ -32,13 +32,6 @@ spec: volumeMounts: - name: postgres-storage mountPath: /var/lib/postgresql/data - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi readinessProbe: exec: command: @@ -165,7 +158,7 @@ spec: env: - name: GF_PATHS_CONFIG value: /etc/grafana-config/grafana.ini - image: grafana/grafana-dev:12.2.0-257970 + image: grafana/grafana-dev:12.3.0-17863745596 imagePullPolicy: IfNotPresent name: zanzana ports: From 6c4be6d1ea8f039f4a8b517f3e25ebf7244f3d48 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Thu, 25 Sep 2025 18:48:29 -0300 Subject: [PATCH 05/31] Saved Queries: Remove unused feature toggle (#111321) Co-authored-by: nmarrs --- .../configure-grafana/feature-toggles/index.md | 2 +- packages/grafana-data/src/types/featureToggles.gen.ts | 6 +----- pkg/services/featuremgmt/registry.go | 10 +--------- pkg/services/featuremgmt/toggles_gen.csv | 3 +-- pkg/services/featuremgmt/toggles_gen.go | 6 +----- pkg/services/featuremgmt/toggles_gen.json | 9 +++++---- 6 files changed, 10 insertions(+), 26 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 8425568706e..f387da5857b 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -103,7 +103,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `regressionTransformation` | Enables regression analysis transformation | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage | | `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. | -| `savedQueries` | Enables Saved Queries feature | +| `queryLibrary` | Enables Saved queries (query library) feature | | `enableSCIM` | Enables SCIM support for user and group management | | `alertRuleRestore` | Enables the alert rule restore feature | | `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3d92d782abe..58cf53cfb2d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -549,14 +549,10 @@ export interface FeatureToggles { */ grafanaManagedRecordingRules?: boolean; /** - * Renamed feature toggle, enables Saved queries feature + * Enables Saved queries (query library) feature */ queryLibrary?: boolean; /** - * Enables Saved Queries feature - */ - savedQueries?: boolean; - /** * Sets the logs table as default visualisation in logs explore */ logsExploreTableDefaultVisualization?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index dc710222b16..15bf3d45d39 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -942,15 +942,7 @@ var ( }, { Name: "queryLibrary", - Description: "Renamed feature toggle, enables Saved queries feature", - Stage: FeatureStagePrivatePreview, - Owner: grafanaSharingSquad, - FrontendOnly: false, - AllowSelfServe: false, - }, - { - Name: "savedQueries", - Description: "Enables Saved Queries feature", + Description: "Enables Saved queries (query library) feature", Stage: FeatureStagePublicPreview, Owner: grafanaSharingSquad, FrontendOnly: false, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c2642ddffbe..a1f04785edc 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -122,8 +122,7 @@ newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,f cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false -queryLibrary,privatePreview,@grafana/sharing-squad,false,false,false -savedQueries,preview,@grafana/sharing-squad,false,false,false +queryLibrary,preview,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1d418977c77..4cddb8fe39a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -500,13 +500,9 @@ const ( FlagGrafanaManagedRecordingRules = "grafanaManagedRecordingRules" // FlagQueryLibrary - // Renamed feature toggle, enables Saved queries feature + // Enables Saved queries (query library) feature FlagQueryLibrary = "queryLibrary" - // FlagSavedQueries - // Enables Saved Queries feature - FlagSavedQueries = "savedQueries" - // FlagLogsExploreTableDefaultVisualization // Sets the logs table as default visualisation in logs explore FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66a6fa28684..cbc007c0902 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2976,16 +2976,16 @@ { "metadata": { "name": "queryLibrary", - "resourceVersion": "1755721444487", + "resourceVersion": "1758208636622", "creationTimestamp": "2022-10-07T18:31:45Z", "deletionTimestamp": "2023-03-20T16:00:14Z", "annotations": { - "grafana.app/updatedTimestamp": "2025-08-20 20:24:04.487598 +0000 UTC" + "grafana.app/updatedTimestamp": "2025-09-18 15:17:16.622111 +0000 UTC" } }, "spec": { - "description": "Renamed feature toggle, enables Saved queries feature", - "stage": "privatePreview", + "description": "Enables Saved queries (query library) feature", + "stage": "preview", "codeowner": "@grafana/sharing-squad" } }, @@ -3186,6 +3186,7 @@ "name": "savedQueries", "resourceVersion": "1756920131554", "creationTimestamp": "2025-08-25T21:22:09Z", + "deletionTimestamp": "2025-09-18T12:07:31Z", "annotations": { "grafana.app/updatedTimestamp": "2025-09-03 17:22:11.554759 +0000 UTC" } From 4ea85b7feb77cffd436ad2cf0353dc86672b2e92 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:28:33 +0200 Subject: [PATCH 06/31] Alerting: Fix threshold params (#111645) * fix threshold params * update tests --- .../components/__snapshots__/hysteresis.test.ts.snap | 2 -- public/app/features/expressions/components/hysteresis.test.ts | 2 +- public/app/features/expressions/components/thresholdReducer.ts | 3 +++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap b/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap index 452fcd7305a..6af902b384d 100644 --- a/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap +++ b/public/app/features/expressions/components/__snapshots__/hysteresis.test.ts.snap @@ -72,7 +72,6 @@ exports[`thresholdReducer should update Threshold Type, and unloadEvaluator para { "evaluator": { "params": [ - 10, 0, ], "type": "lt", @@ -90,7 +89,6 @@ exports[`thresholdReducer should update Threshold Type, and unloadEvaluator para "type": "query", "unloadEvaluator": { "params": [ - 10, 0, ], "type": "gt", diff --git a/public/app/features/expressions/components/hysteresis.test.ts b/public/app/features/expressions/components/hysteresis.test.ts index 7f802aa13f7..13a87d022e3 100644 --- a/public/app/features/expressions/components/hysteresis.test.ts +++ b/public/app/features/expressions/components/hysteresis.test.ts @@ -159,7 +159,7 @@ describe('thresholdReducer', () => { expect(newState.conditions[0].evaluator.type).toEqual(EvalFunction.IsBelow); expect(newState.conditions[0].unloadEvaluator?.type).toEqual(EvalFunction.IsAbove); expect(onError).toHaveBeenCalledWith(undefined); - expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(10); + expect(newState.conditions[0].unloadEvaluator?.params[0]).toEqual(0); }); it('Should update unlooadEvaluator when checking hysteresis', () => { const initialState: ThresholdExpressionQuery = { diff --git a/public/app/features/expressions/components/thresholdReducer.ts b/public/app/features/expressions/components/thresholdReducer.ts index 503baa3e727..17ccf5d233b 100644 --- a/public/app/features/expressions/components/thresholdReducer.ts +++ b/public/app/features/expressions/components/thresholdReducer.ts @@ -33,6 +33,9 @@ export const thresholdReducer = createReducer( //set new type in evaluator state.conditions[0].evaluator.type = typeInPayload; + //reset params + state.conditions[0].evaluator.params = [0]; + // check if hysteresis is checked const hsyteresisIsChecked = Boolean(state.conditions[0].unloadEvaluator); From c2a38d6e0b8294225447595452759f7939f3fc49 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:08:41 +0300 Subject: [PATCH 07/31] Dashboards: Fix new metrics instrumentation type (#111587) fix --- .../serialization/transformSaveModelSchemaV2ToScene.ts | 3 ++- .../dashboard-scene/serialization/transformSaveModelToScene.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 3162c7282dd..d352c75a5ec 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -53,6 +53,7 @@ import { } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { + getDashboardComponentInteractionCallback, getDashboardInteractionCallback, getDashboardSceneProfiler, } from 'app/features/dashboard/services/DashboardProfiler'; @@ -176,7 +177,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo uid === '*' || uid === metadata.name) !== -1, - onInteractionComplete: getDashboardInteractionCallback(metadata.name, dashboard.title), + onInteractionComplete: getDashboardComponentInteractionCallback(metadata.name, dashboard.title), }, getDashboardSceneProfiler() ); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index bc9c938929e..8c3f3559030 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -23,6 +23,7 @@ import { import { isWeekStart } from '@grafana/ui'; import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1'; import { + getDashboardComponentInteractionCallback, getDashboardInteractionCallback, getDashboardSceneProfiler, } from 'app/features/dashboard/services/DashboardProfiler'; @@ -309,7 +310,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, { enableInteractionTracking: config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1, - onInteractionComplete: getDashboardInteractionCallback(oldModel.uid, oldModel.title), + onInteractionComplete: getDashboardComponentInteractionCallback(oldModel.uid, oldModel.title), }, getDashboardSceneProfiler() ); From 9ac5a0b002371ec252a4c60fed23ffbc9dc91aa5 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:09:20 +0300 Subject: [PATCH 08/31] Dashboards: Add network call checks for reloadable dashboards in E2Es (#111380) * Add network call verification for reloadable dashboards * refactor --- .../dashboard-navigation.spec.ts | 10 ++- .../dashboard-cujs/dashboard-view.spec.ts | 8 ++- e2e-playwright/dashboard-cujs/utils.ts | 72 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts index c454bc69291..008ca89ef7f 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts @@ -11,7 +11,7 @@ import { getScopesDashboardsSearchInput, getScopesSelectorInput, } from './cuj-selectors'; -import { getConfigDashboards } from './utils'; +import { checkDashboardReloadBehavior, getConfigDashboards, trackDashboardReloadRequests } from './utils'; test.use({ featureToggles: { @@ -118,8 +118,14 @@ test.describe( await groupByVariable.press('Escape'); await expect(scopesDashboards.first()).toBeVisible(); + + const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page); await scopesDashboards.first().click(); - await page.waitForURL('**/d/**'); + await waitForExpectedRequests(); + await page.waitForLoadState('networkidle'); + + const requests = getRequests(); + expect(checkDashboardReloadBehavior(requests)).toBe(true); //all values are set after dashboard switch await expect(markdownContent).toContainText(`GroupByVar: dev\n\nAdHocVar: ${processedPills}`); diff --git a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts index 9fccafada83..53dd02a0314 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { getConfigDashboards } from './utils'; +import { getConfigDashboards, trackDashboardReloadRequests, checkDashboardReloadBehavior } from './utils'; test.use({ featureToggles: { @@ -27,7 +27,13 @@ test.describe( for (const db of dashboards) { await test.step('1.Loads dashboard successfully - ' + db, async () => { + const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page); const dashboardPage = await gotoDashboardPage({ uid: db }); + await waitForExpectedRequests(); + await page.waitForLoadState('networkidle'); + + const requests = getRequests(); + expect(checkDashboardReloadBehavior(requests)).toBe(true); const panelTitle = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Panel.title(PANEL_UNDER_TEST) diff --git a/e2e-playwright/dashboard-cujs/utils.ts b/e2e-playwright/dashboard-cujs/utils.ts index 393aee8b5f5..f12f4960fae 100644 --- a/e2e-playwright/dashboard-cujs/utils.ts +++ b/e2e-playwright/dashboard-cujs/utils.ts @@ -5,6 +5,8 @@ import * as path from 'path'; const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); const API_CONFIG_PATH = process.env.API_CONFIG_PATH ?? '../dashboards/cujs/config.json'; +const RELOADABLE_DASHBOARD_REQUEST_NO = 2; + async function loadApiConfig() { const configPath = path.resolve(__dirname, API_CONFIG_PATH); @@ -63,3 +65,73 @@ export async function prepareAPIMocks(page: Page) { return apiConfig; } + +interface DashboardRequest { + url: string; + timestamp: number; + response: { metadata: { annotations: string[] } }; +} + +export async function trackDashboardReloadRequests(page: Page): Promise<{ + getRequests: () => DashboardRequest[]; + waitForExpectedRequests: () => Promise; +}> { + const dashboardRequests: DashboardRequest[] = []; + let resolveWhenComplete: () => void; + let expectedRequestCount = 1; //initial request that gives us the meta param + + const completionPromise = new Promise((resolve) => { + resolveWhenComplete = resolve; + }); + + const handler = async (route) => { + const response = await route.fetch(); + const responseJson = await response.json(); + const isFirstRequest = dashboardRequests.length === 0; + + dashboardRequests.push({ + url: route.request().url(), + timestamp: Date.now(), + response: responseJson, + }); + + // After first request, check if we should expect more + if (isFirstRequest) { + const hasReloadAnnotation = responseJson?.metadata?.annotations?.['grafana.app/reloadOnParamsChange'] === 'true'; + + if (hasReloadAnnotation) { + expectedRequestCount = RELOADABLE_DASHBOARD_REQUEST_NO; + } + } + + // Resolve if we've reached the expected count + if (dashboardRequests.length >= expectedRequestCount) { + resolveWhenComplete(); + } + + await route.fulfill({ response }); + }; + + await page.route('**/dashboards/**/dto?**', handler); + + return { + getRequests: () => dashboardRequests, + waitForExpectedRequests: () => completionPromise, + }; +} + +export function checkDashboardReloadBehavior(requests: DashboardRequest[]): boolean { + if (requests.length === 0) { + return false; + } + + const firstRequest = requests[0]; + const hasReloadAnnotation = + firstRequest?.response?.metadata?.annotations?.['grafana.app/reloadOnParamsChange'] === 'true'; + + if (hasReloadAnnotation) { + return requests.length === RELOADABLE_DASHBOARD_REQUEST_NO; + } else { + return requests.length === 1; + } +} From 23fd69f57277a6d59d78e18dd520bc32cde65ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Fri, 26 Sep 2025 10:10:05 +0200 Subject: [PATCH 09/31] Flame Graph: Use suffix for values formatted with a short formatter (#110999) * Flame Graph: Use suffix for values formatted with a short formatter * Fix linting --- .../src/FlameGraph/FlameGraphTooltip.test.tsx | 44 +++++++++++++++++++ .../src/FlameGraph/FlameGraphTooltip.tsx | 15 ++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.test.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.test.tsx index 7ad62ca5f78..630d97b6f4c 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.test.tsx @@ -111,6 +111,20 @@ describe('FlameGraphTooltip', () => { }); }); +function setupDiffData2() { + const flameGraphData = createDataFrame({ + fields: [ + { name: 'level', values: [0, 1] }, + { name: 'value', values: [101, 101] }, + { name: 'valueRight', values: [100, 100] }, + { name: 'self', values: [100, 100] }, + { name: 'selfRight', values: [1, 1] }, + { name: 'label', values: ['total', 'func1'] }, + ], + }); + return new FlameGraphDataContainer(flameGraphData, { collapsing: true }); +} + describe('getDiffTooltipData', () => { it('works with diff data', () => { const tooltipData = getDiffTooltipData( @@ -142,6 +156,36 @@ describe('getDiffTooltipData', () => { }, ]); }); + it('works with diff data and short values', () => { + const tooltipData = getDiffTooltipData( + setupDiffData2(), + { start: 0, itemIndexes: [1], value: 101, valueRight: 100, children: [], level: 0 }, + 200 + ); + expect(tooltipData).toEqual([ + { + rowId: '1', + label: '% of total', + baseline: '1%', + comparison: '100%', + diff: '9.90 K%', + }, + { + rowId: '2', + label: 'Value', + baseline: '1', + comparison: '100', + diff: '99', + }, + { + rowId: '3', + label: 'Samples', + baseline: '1', + comparison: '100', + diff: '99', + }, + ]); + }); }); function makeField(name: string, unit: string, values: number[]): Field { diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx index b9dca5a3a94..42e0f331764 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { DisplayValue, getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { DisplayValue, getValueFormat, GrafanaTheme2, ValueFormatter } from '@grafana/data'; import { InteractiveTable, Portal, useStyles2, VizTooltipContainer } from '@grafana/ui'; import { CollapseConfig, FlameGraphDataContainer, LevelItem } from './dataTransform'; @@ -122,6 +122,11 @@ type DiffTableData = { diff: string | number; }; +const formatWithSuffix = (value: number, formatter: ValueFormatter): string => { + const displayValue = formatter(value); + return displayValue.text + displayValue.suffix; +}; + export const getDiffTooltipData = ( data: FlameGraphDataContainer, item: LevelItem, @@ -148,7 +153,7 @@ export const getDiffTooltipData = ( label: '% of total', baseline: percentageLeft + '%', comparison: percentageRight + '%', - diff: shortValFormat(diff).text + '%', + diff: formatWithSuffix(diff, shortValFormat) + '%', }, { rowId: '2', @@ -160,9 +165,9 @@ export const getDiffTooltipData = ( { rowId: '3', label: 'Samples', - baseline: shortValFormat(valueLeft).text, - comparison: shortValFormat(item.valueRight!).text, - diff: shortValFormat(item.valueRight! - valueLeft).text, + baseline: formatWithSuffix(valueLeft, shortValFormat), + comparison: formatWithSuffix(item.valueRight!, shortValFormat), + diff: formatWithSuffix(item.valueRight! - valueLeft, shortValFormat), }, ]; }; From ba7603bd4b2d8de81a6b3b578b4b19fbc4645460 Mon Sep 17 00:00:00 2001 From: John-George Sample Date: Fri, 26 Sep 2025 04:23:50 -0400 Subject: [PATCH 10/31] Remove unused `style` prop from FlameGraph (#110459) * remove `style` prop * chore: linting --- packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index a6c78f8bbeb..fbe14d1d281 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -18,7 +18,6 @@ // THIS SOFTWARE. import { css, cx } from '@emotion/css'; import { useEffect, useState } from 'react'; -import * as React from 'react'; import { Icon } from '@grafana/ui'; @@ -37,7 +36,6 @@ type Props = { matchedLabels?: Set; setRangeMin: (range: number) => void; setRangeMax: (range: number) => void; - style?: React.CSSProperties; onItemFocused: (data: ClickedItemData) => void; focusedItemData?: ClickedItemData; textAlign: TextAlign; From a32687838e7b65356a2598563bd56bccfe7371d1 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:33:26 +0000 Subject: [PATCH 11/31] I18n: Download translations from Crowdin (#111650) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 57 +++++++++++++++++++---------- public/locales/de-DE/grafana.json | 57 +++++++++++++++++++---------- public/locales/es-ES/grafana.json | 57 +++++++++++++++++++---------- public/locales/fr-FR/grafana.json | 57 +++++++++++++++++++---------- public/locales/hu-HU/grafana.json | 57 +++++++++++++++++++---------- public/locales/id-ID/grafana.json | 57 +++++++++++++++++++---------- public/locales/it-IT/grafana.json | 57 +++++++++++++++++++---------- public/locales/ja-JP/grafana.json | 57 +++++++++++++++++++---------- public/locales/ko-KR/grafana.json | 57 +++++++++++++++++++---------- public/locales/nl-NL/grafana.json | 57 +++++++++++++++++++---------- public/locales/pl-PL/grafana.json | 57 +++++++++++++++++++---------- public/locales/pt-BR/grafana.json | 57 +++++++++++++++++++---------- public/locales/pt-PT/grafana.json | 57 +++++++++++++++++++---------- public/locales/ru-RU/grafana.json | 57 +++++++++++++++++++---------- public/locales/sv-SE/grafana.json | 57 +++++++++++++++++++---------- public/locales/tr-TR/grafana.json | 53 ++++++++++++++++++--------- public/locales/zh-Hans/grafana.json | 57 +++++++++++++++++++---------- public/locales/zh-Hant/grafana.json | 57 +++++++++++++++++++---------- 18 files changed, 664 insertions(+), 358 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index a5b30c2d737..b7544955223 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -1681,6 +1681,10 @@ "inspector-yaml-tab": { "apply": "Použít" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Jak se připojit k IRM", "disabled-description": "Povolit IRM prostřednictvím integrace webhooku", @@ -1825,6 +1829,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtrovat výstrahy pomocí dotazování na štítky bez složených závorek, např.:", "filter-alerts-using-label-querying-without-spaces": "Filtrujte výstrahy pomocí dotazu na štítky bez mezer, např.:", @@ -1987,8 +1995,10 @@ "label-notification-policies": "Zásady oznamování", "label-time-intervals": "Časové intervaly" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Výchozí zásada", "no-matchers": "Žádné shody" }, "notification-preview": { @@ -2005,22 +2015,12 @@ }, "notification-route": { "no-labels": "Žádné štítky", - "no-matching-labels": "Žádné shodné štítky" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Vaše instance výstrah jsou přesměrovány následujícím způsobem.", - "close": "Zavřít", - "contact-point": "Kontaktní bod", - "default-policy": "Výchozí zásada", - "notification-policy-path": "Cesta k zásadě oznamování", - "see-details-link": "Zobrazit detaily", - "title-routing-details": "Podrobnosti o směrování" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Rozbalit trasu zásad", - "delivered-to": "@doručeno:", - "notification-policy": "Zásada oznamování", - "see-details": "Zobrazit detaily" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2153,10 +2153,6 @@ "label-new-sibling-above": "Nová souvislost nahoře", "label-new-sibling-below": "Nová souvislost dole" }, - "policy-path": { - "default-policy": "Výchozí zásada", - "no-matchers": "Žádné shody" - }, "preview-rule": { "body-preview-is-not-available": "Náhled dotazu nelze zobrazit. Některé zdroje dat použité v dotazech nejsou k dispozici.", "preview-alerts": "Zobrazit náhled výstrah", @@ -9403,6 +9399,7 @@ }, "share-panel": { "config-description": "Vytvořte personalizovaný přímý odkaz pro sdílení panelu v rámci vaší organizace s následujícími možnostmi přizpůsobení:", + "copy-image-link": "", "download-image": "Stáhnout obrázek", "render-image": "Generovat obrázek" } @@ -10719,6 +10716,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11403,6 +11408,7 @@ "actions": { "set-up-required-feature-toggles": "Nastavit povinné přepínače funkcí" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Spravujte nástěnky jako kód v Git a automaticky zajišťujte aktualizace", "manage-your-dashboards-with-remote-provisioning": "Začínáme se synchronizací Git", "store-dashboards-in-version-controlled-storage": "Ukládejte nástěnky do úložiště s pevně nastavenou verzí pro lepší organizaci a sledování historie" @@ -11596,8 +11602,16 @@ "subtitle": "Tuto možnost použijte, pokud chcete synchronizovat a spravovat celou instanci Grafany prostřednictvím externího úložiště." } }, - "read-only-local-tooltip": "Tato složka je jen pro čtení a je zajištěna prostřednictvím zajištění souborů. Chcete-li provést jakékoli změny ve složce, aktualizujte připojené úložiště souborů. Chcete-li upravit nastavení složky, přejděte na Správa > Zajištění > Úložiště.", - "read-only-remote-tooltip": "Tato složka je jen pro čtení a je zajištěna prostřednictvím služby Git. Chcete-li provést jakékoli změny ve složce, aktualizujte připojené úložiště. Chcete-li upravit nastavení složky, přejděte do části Správa > Zajištění > Úložiště.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "aktivní práce", "column-action": "Akce", @@ -11711,6 +11725,7 @@ "removed-from-repo-test-ui": "odebráno z uživatelského rozhraní repo testu", "updated-from-repo-test-ui": "aktualizováno z uživatelského rozhraní repo testu" }, + "read-only-repo-tooltip": "", "repository": "Úložiště", "tab-info": { "label": { @@ -11747,6 +11762,8 @@ "tooltip-unhealthy-repository": "Nelze stáhnout nezdravé úložiště" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Zahrnout předložení pro každou historickou hodnotu", "synchronization-options": "Možnosti synchronizace" diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 83a4aef9486..c38d7565821 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Anwenden" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "So stellen Sie eine Verbindung zu IRM her", "disabled-description": "IRM über eine Webhook-Integration aktivieren", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtern Sie Warnungen mithilfe von Labels, ohne Klammern zu verwenden, z. B.:", "filter-alerts-using-label-querying-without-spaces": "Filtern Sie Warnungen mithilfe von Label-Abfragen, ohne Leerzeichen; Beispiel:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Benachrichtigungsrichtlinien", "label-time-intervals": "Zeitintervalle" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Standardrichtlinie", "no-matchers": "Keine Matcher" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Keine Labels", - "no-matching-labels": "Keine übereinstimmenden Labels" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Ihre Warnungsinstanzen werden wie folgt weitergeleitet.", - "close": "Schließen", - "contact-point": "Kontaktpunkt", - "default-policy": "Standardrichtlinie", - "notification-policy-path": "Pfad der Benachrichtigungsrichtlinie", - "see-details-link": "Details anzeigen", - "title-routing-details": "Weiterleitungsdetails" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Richtlinienroute erweitern", - "delivered-to": "@ Übermittelt an", - "notification-policy": "Benachrichtigungsrichtlinie", - "see-details": "Details anzeigen" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Neues gleichrangiges Element oben", "label-new-sibling-below": "Neues gleichrangiges Element unten" }, - "policy-path": { - "default-policy": "Standardrichtlinie", - "no-matchers": "Keine Matcher" - }, "preview-rule": { "body-preview-is-not-available": "Die Abfragevorschau kann nicht angezeigt werden. Einige der in den Abfragen verwendeten Datenquellen sind nicht verfügbar.", "preview-alerts": "Vorschau der Warnungen ansehen", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Erstellen Sie einen personalisierten, direkten Link, um Ihr Panel innerhalb Ihrer Organisation mit den folgenden Anpassungseinstellungen zu teilen:", + "copy-image-link": "", "download-image": "Bild herunterladen", "render-image": "Bild erstellen" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Benötigte Funktionsschalter einrichten" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Verwalten Sie Dashboards als Code in Git und stellen Sie Updates automatisch bereit", "manage-your-dashboards-with-remote-provisioning": "Erste Schritte mit Git Sync", "store-dashboards-in-version-controlled-storage": "Speichern Sie Dashboards im versionskontrollierten Speicher für eine bessere Organisation und Verlaufsverfolgung" @@ -11516,8 +11522,16 @@ "subtitle": "Nutzen Sie diese Option, wenn Sie Ihre gesamte Grafana-Instanz über einen externen Speicher synchronisieren und verwalten möchten." } }, - "read-only-local-tooltip": "Dieser Ordner ist schreibgeschützt und wird über die Dateibereitstellung bereitgestellt. Um Änderungen am Ordner vorzunehmen, aktualisieren Sie bitte das verbundene Datei-Repository. Um die Ordnereinstellungen zu ändern, gehen Sie zu Administration > Bereitstellung > Repositorys.", - "read-only-remote-tooltip": "Dieser Ordner ist schreibgeschützt und wird über Git bereitgestellt. Um Änderungen am Ordner vorzunehmen, aktualisieren Sie bitte das verbundene Repository. Um die Ordnereinstellungen zu ändern, gehen Sie zu Administration > Bereitstellung > Repositorys.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "Aktive Aufträge", "column-action": "Aktion", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "von der Repo-Test-UI entfernt", "updated-from-repo-test-ui": "von der Repo-Test-UI aktualisiert" }, + "read-only-repo-tooltip": "", "repository": "Repository", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Ein fehlerhaftes Repository kann nicht abgerufen werden" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Commits für jeden historischen Wert erfassen", "synchronization-options": "Synchronisierungsoptionen" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index aeff0bbc3b3..179fec865b6 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Aplicar" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Cómo conectarse a IRM", "disabled-description": "Habilitar IRM a través de una integración del webhook", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtrar alertas mediante consulta de etiquetas sin corchetes. Por ej.:", "filter-alerts-using-label-querying-without-spaces": "Filtra alertas mediante una consulta de etiquetas sin espacios. Por ejemplo:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Políticas de notificación", "label-time-intervals": "Intervalos de tiempo" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Política predeterminada", "no-matchers": "Ningún buscador de coincidencias" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Ninguna etiqueta", - "no-matching-labels": "Ninguna etiqueta coincidente" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Tus instancias de alerta se enrutan de la siguiente manera.", - "close": "Cerrar", - "contact-point": "Punto de contacto", - "default-policy": "Política predeterminada", - "notification-policy-path": "Ruta de la política de notificación", - "see-details-link": "Ver detalles", - "title-routing-details": "Detalles de enrutamiento" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Expandir ruta de política", - "delivered-to": "@ Entregado a", - "notification-policy": "Política de notificación", - "see-details": "Ver detalles" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Nuevo elemento del mismo nivel superior", "label-new-sibling-below": "Nuevo elemento del mismo nivel inferior" }, - "policy-path": { - "default-policy": "Política predeterminada", - "no-matchers": "Ningún buscador de coincidencias" - }, "preview-rule": { "body-preview-is-not-available": "No se puede mostrar la vista previa de la consulta. Algunas de las fuentes de datos utilizadas en las consultas no están disponibles.", "preview-alerts": "Vista previa de las alertas", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Crea un enlace personalizado y directo para compartir tu panel dentro de tu organización, con los siguientes ajustes de personalización:", + "copy-image-link": "", "download-image": "Descargar imagen", "render-image": "Generar imagen" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Configurar los conmutadores de características necesarios" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gestiona los dashboards como código en Git y aprovisiona las actualizaciones de forma automática", "manage-your-dashboards-with-remote-provisioning": "Empezar con Git Sync", "store-dashboards-in-version-controlled-storage": "Almacena los dashboards en un almacenamiento controlado por versiones para mejorar la organización y el seguimiento del historial" @@ -11516,8 +11522,16 @@ "subtitle": "Utiliza esta opción si quieres sincronizar y administrar toda tu instancia de Grafana a través de un almacenamiento externo." } }, - "read-only-local-tooltip": "Esta carpeta es de solo lectura y se aprovisiona con el aprovisionamiento de archivos. Para realizar cualquier cambio en ella, actualiza el repositorio de archivos conectado. Para modificar la configuración de la carpeta, ve a Administración > Aprovisionamiento > Repositorios.", - "read-only-remote-tooltip": "Esta carpeta es de solo lectura y se aprovisiona a través de Git. Para realizar cualquier cambio en ella, actualiza el repositorio conectado. Para modificarla, ve a Administración > Aprovisionamiento > Repositorios.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "trabajos activos", "column-action": "Acción", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "eliminado de la interfaz de usuario de prueba de repositorio", "updated-from-repo-test-ui": "actualizado desde la interfaz de usuario de prueba de repositorio" }, + "read-only-repo-tooltip": "", "repository": "Repositorio", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "No se puede extraer un repositorio que no está en buen estado" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Incluir confirmaciones para cada valor histórico", "synchronization-options": "Opciones de sincronización" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 4789550f125..9679c5fe760 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Appliquer" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Comment se connecter à IRM", "disabled-description": "Activer IRM via une intégration Webhook", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtrer les alertes à l'aide de la méthode de recherche d'étiquettes sans accolades, par exemple :", "filter-alerts-using-label-querying-without-spaces": "Filtrer les alertes à l’aide de la méthode de recherche d’étiquettes sans espaces, par exemple :", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Politiques de notification", "label-time-intervals": "Intervalles de temps" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Politique par défaut", "no-matchers": "Aucun correspondant" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Pas d’étiquettes", - "no-matching-labels": "Pas d’étiquettes correspondantes" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Vos instances d’alerte sont acheminées comme suit.", - "close": "Fermer", - "contact-point": "Point de contact", - "default-policy": "Politique par défaut", - "notification-policy-path": "Chemin de la politique de notification", - "see-details-link": "Afficher les détails", - "title-routing-details": "Détails de l’acheminement" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Développer le chemin de la politique", - "delivered-to": "@ Livré à", - "notification-policy": "Politique de notification", - "see-details": "Afficher les détails" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Nouvel élément frère au-dessus", "label-new-sibling-below": "Nouvel élément frère au-dessous" }, - "policy-path": { - "default-policy": "Politique par défaut", - "no-matchers": "Aucun correspondant" - }, "preview-rule": { "body-preview-is-not-available": "Impossible d'afficher l'aperçu de la requête. Certaines des sources de données utilisées dans les requêtes ne sont pas disponibles.", "preview-alerts": "Aperçu des alertes", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Créez un lien direct personnalisé pour partager votre panneau au sein de votre organisation, avec les paramètres de personnalisation suivants :", + "copy-image-link": "", "download-image": "Télécharger l'image", "render-image": "Générer l'image" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Configurer les basculements de fonctionnalité requis" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gérez les tableaux de bord as code sur Git et approvisionnez automatiquement les mises à jour", "manage-your-dashboards-with-remote-provisioning": "Commencer avec Git Sync", "store-dashboards-in-version-controlled-storage": "Stockez les tableaux de bord dans un espace de stockage à gestion des versions pour une meilleure organisation et un meilleur suivi de l’historique" @@ -11516,8 +11522,16 @@ "subtitle": "Utilisez cette option si vous souhaitez synchroniser et gérer l’ensemble de votre instance Grafana via un stockage externe." } }, - "read-only-local-tooltip": "Ce dossier est en lecture seule et provisionné via des fichiers. Pour apporter des modifications dans le dossier, mettez à jour le référentiel de fichiers connecté. Pour modifier les paramètres du dossier, accédez à Administration > Provisionnement > Référentiels.", - "read-only-remote-tooltip": "Ce dossier est en lecture seule et provisionné via Git. Pour apporter des modifications dans le dossier, mettez à jour le référentiel connecté. Pour modifier les paramètres du dossier, accédez à Administration > Provisionnement > Référentiels.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "missions actives", "column-action": "Action", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "supprimé de l’interface utilisateur de test de référentiel", "updated-from-repo-test-ui": "mis à jour à partir de l’interface utilisateur de test de référentiel" }, + "read-only-repo-tooltip": "", "repository": "Référentiel", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Impossible de fusionner un référentiel en mauvais état" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Inclure les commits pour chaque valeur historique", "synchronization-options": "Options de synchronisation" diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 08c0b65f753..b5effc40772 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Alkalmaz" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Csatlakozás az IRM-hez", "disabled-description": "Az IRM engedélyezése webkapocs-integráción keresztül", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Szűrheti a riasztásokat zárójelek nélküli címkelekérdezéssel, pl.:", "filter-alerts-using-label-querying-without-spaces": "Szűrheti a riasztásokat szóközök nélküli címkelekérdezéssel, pl.:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Értesítési házirendek", "label-time-intervals": "Időintervallumok" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Alapértelmezett házirend", "no-matchers": "Nincs egyezés" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Nincs címke", - "no-matching-labels": "Nincs egyező címke" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Riasztáspéldányai a következők szerint irányítottak.", - "close": "Bezárás", - "contact-point": "Kapcsolattartási pont", - "default-policy": "Alapértelmezett házirend", - "notification-policy-path": "Értesítési házirend útvonala", - "see-details-link": "Részletek megtekintése", - "title-routing-details": "Irányítás részletei" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Házirend útvonalának kibontása", - "delivered-to": "@ Kézbesítve ide:", - "notification-policy": "Értesítési házirend", - "see-details": "Részletek megtekintése" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Új testvér feljebb", "label-new-sibling-below": "Új testvér lejjebb" }, - "policy-path": { - "default-policy": "Alapértelmezett házirend", - "no-matchers": "Nincs egyezés" - }, "preview-rule": { "body-preview-is-not-available": "Nem lehet megjeleníteni a lekérdezés előnézetét. A lekérdezésekben használt adatforrások némelyike nem érhető el.", "preview-alerts": "Riasztások előnézete", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Létrehozhat egy személyre szabott, közvetlen hivatkozást a panel szervezeten belüli megosztásához, a következő testreszabási beállításokkal:", + "copy-image-link": "", "download-image": "Kép letöltése", "render-image": "Kép generálása" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Kötelező funkcióváltások beállítása" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Irányítópultok kezelése kódként a Gitben, és a kiépítés automatikus frissítése", "manage-your-dashboards-with-remote-provisioning": "A Git-szinkronizálás használatának megkezdése", "store-dashboards-in-version-controlled-storage": "Tárolja az irányítópultokat verziókövetéses tárolóban a jobb rendszerezés és az előzmények nyomon követése érdekében" @@ -11516,8 +11522,16 @@ "subtitle": "Használja ezt a lehetőséget, ha külső tárolóeszközön keresztül szeretné szinkronizálni és kezelni a teljes Grafana-példányt." } }, - "read-only-local-tooltip": "Ez a mappa csak olvasható, és fájlalapú konfigurációval rendelkezik. A módosításokhoz frissítse a kapcsolódó fájladattárat. A mappa beállításainak módosításához lépjen az Adminisztráció > Konfiguráció > Adattárak menüpontra.", - "read-only-remote-tooltip": "Ez a mappa csak olvasható, és Git-alapú konfigurációval rendelkezik. A módosításokhoz frissítse a kapcsolódó adattárat. A mappa beállításainak módosításához lépjen az Adminisztráció > Konfiguráció > Adattárak menüpontra.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "aktív feladatok", "column-action": "Művelet", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "eltávolítva az adattár tesztfelületéről", "updated-from-repo-test-ui": "frissítve az adattár tesztfelületéről" }, + "read-only-repo-tooltip": "", "repository": "Adattár", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Nem lehet beolvasni egy nem megfelelő állapotú adattárat" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Változtatások belefoglalása minden előzményértékhez", "synchronization-options": "Szinkronizálási beállítások" diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 6d305d2dc28..fb1006a56ac 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -1666,6 +1666,10 @@ "inspector-yaml-tab": { "apply": "Terapkan" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Cara terhubung ke IRM", "disabled-description": "Aktifkan IRM melalui integrasi Webhook", @@ -1807,6 +1811,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filter peringatan menggunakan kueri label tanpa kurung kurawal, misalnya:", "filter-alerts-using-label-querying-without-spaces": "Filter peringatan menggunakan kueri label tanpa spasi, misalnya:", @@ -1969,8 +1977,10 @@ "label-notification-policies": "Kebijakan Pemberitahuan", "label-time-intervals": "Interval waktu" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Kebijakan default", "no-matchers": "Tidak ada pencocok" }, "notification-preview": { @@ -1987,22 +1997,12 @@ }, "notification-route": { "no-labels": "Tidak ada label", - "no-matching-labels": "Tidak ada label yang cocok" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Instans peringatan Anda dirutekan sebagai berikut.", - "close": "Tutup", - "contact-point": "Titik kontak", - "default-policy": "Kebijakan default", - "notification-policy-path": "Jalur kebijakan pemberitahuan", - "see-details-link": "Lihat detail", - "title-routing-details": "Detail perutean" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Perluas rute kebijakan", - "delivered-to": "@ Dikirimkan ke", - "notification-policy": "Kebijakan pemberitahuan", - "see-details": "Lihat detail" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2129,10 +2129,6 @@ "label-new-sibling-above": "Sibling baru di atas", "label-new-sibling-below": "Sibling baru di bawah" }, - "policy-path": { - "default-policy": "Kebijakan default", - "no-matchers": "Tidak ada pencocok" - }, "preview-rule": { "body-preview-is-not-available": "Tidak dapat menampilkan pratinjau kueri. Beberapa sumber data yang digunakan di kueri tidak tersedia.", "preview-alerts": "Pratinjau peringatan", @@ -9319,6 +9315,7 @@ }, "share-panel": { "config-description": "Buat tautan langsung yang dipersonalisasi untuk membagikan panel Anda dalam organisasi Anda, dengan pengaturan penyesuaian berikut:", + "copy-image-link": "", "download-image": "Unduh gambar", "render-image": "Buat gambar" } @@ -10617,6 +10614,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11289,6 +11294,7 @@ "actions": { "set-up-required-feature-toggles": "Atur tombol fitur yang diwajibkan" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Kelola dasbor sebagai kode di Git dan sediakan pembaruan secara otomatis", "manage-your-dashboards-with-remote-provisioning": "Mulai menggunakan Git Sync", "store-dashboards-in-version-controlled-storage": "Simpan dasbor di penyimpanan yang dikontrol versi untuk organisasi dan pelacakan riwayat yang lebih baik" @@ -11476,8 +11482,16 @@ "subtitle": "Gunakan opsi ini jika Anda ingin menyinkronkan dan mengelola seluruh instans Grafana melalui penyimpanan eksternal." } }, - "read-only-local-tooltip": "Folder ini hanya-baca dan disediakan melalui penyediaan file. Untuk membuat perubahan di folder, perbarui repositori file yang terhubung. Untuk mengubah pengaturan folder, buka Administrasi > Penyediaan > Repositori.", - "read-only-remote-tooltip": "Folder ini hanya-baca dan disediakan melalui Git. Untuk membuat perubahan di folder, perbarui repositori yang terhubung. Untuk mengubah pengaturan folder, buka Administrasi > Penyediaan > Repositori.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "pekerjaan aktif", "column-action": "Tindakan", @@ -11591,6 +11605,7 @@ "removed-from-repo-test-ui": "dihapus dari UI pengujian repo", "updated-from-repo-test-ui": "diperbarui dari UI pengujian repo" }, + "read-only-repo-tooltip": "", "repository": "Repositori", "tab-info": { "label": { @@ -11627,6 +11642,8 @@ "tooltip-unhealthy-repository": "Tidak dapat menerapkan pull pada repositori yang tidak sehat" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Sertakan commit untuk setiap nilai historis", "synchronization-options": "Opsi sinkronisasi" diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 449f0d6ed08..8631f42cd15 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Applica" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Come connettersi a IRM", "disabled-description": "Abilita IRM tramite un'integrazione Webhook", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtra gli avvisi utilizzando la query dell'etichetta senza parentesi graffe, ad esempio:", "filter-alerts-using-label-querying-without-spaces": "Filtra gli avvisi utilizzando la query dell'etichetta senza spazi, ad esempio:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Criteri di Notifica", "label-time-intervals": "Intervalli di tempo" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Criterio predefinito", "no-matchers": "Nessuna corrispondenza" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Nessuna etichetta", - "no-matching-labels": "Nessuna etichetta corrispondente" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Le istanze di avviso vengono instradate come segue.", - "close": "Chiudi", - "contact-point": "Punto di contatto", - "default-policy": "Criterio predefinito", - "notification-policy-path": "Percorso del criterio di notifica", - "see-details-link": "Visualizza i dettagli", - "title-routing-details": "Dettagli di instradamento" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Espandi percorso del criterio", - "delivered-to": "@ Consegnato a", - "notification-policy": "Criterio di notifica", - "see-details": "Visualizza i dettagli" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Nuovo nodo fratello in alto", "label-new-sibling-below": "Nuovo nodo fratello in basso" }, - "policy-path": { - "default-policy": "Criterio predefinito", - "no-matchers": "Nessuna corrispondenza" - }, "preview-rule": { "body-preview-is-not-available": "Impossibile visualizzare l'anteprima della query. Alcune delle origini dati utilizzate nelle query non sono disponibili.", "preview-alerts": "Anteprima avvisi", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Crea un link diretto e personalizzato per condividere il pannello all'interno della tua organizzazione, con le seguenti impostazioni di personalizzazione:", + "copy-image-link": "", "download-image": "Scarica immagine", "render-image": "Genera immagine" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Configura i toggle delle funzionalità necessarie" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gestisci le dashboard come codice in Git e fornisci automaticamente gli aggiornamenti", "manage-your-dashboards-with-remote-provisioning": "Inizia a usare la sincronizzazione Git", "store-dashboards-in-version-controlled-storage": "Archivia le dashboard in una memoria controllata dalla versione per una migliore organizzazione e monitoraggio della cronologia" @@ -11516,8 +11522,16 @@ "subtitle": "Utilizza questa opzione se desideri sincronizzare e gestire l'intera istanza di Grafana tramite la memoria esterna." } }, - "read-only-local-tooltip": "Questa cartella è di sola lettura e viene fornita tramite il provisioning dei file. Per apportare modifiche alla cartella, aggiorna il repository dei file connesso. Per modificare le impostazioni della cartella, vai su Amministrazione > Provisioning > Repository.", - "read-only-remote-tooltip": "Questa cartella è di sola lettura e viene fornita tramite Git. Per apportare modifiche alla cartella, aggiorna il repository connesso. Per modificare le impostazioni della cartella, vai su Amministrazione > Provisioning > Repository.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "attività attive", "column-action": "Azione", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "rimosso dall'interfaccia utente del test del repository", "updated-from-repo-test-ui": "aggiornato dall'interfaccia utente del test del repository" }, + "read-only-repo-tooltip": "", "repository": "Repository", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Impossibile eseguire il pull di un repository non integro" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Includi commit per ogni valore storico", "synchronization-options": "Opzioni di sincronizzazione" diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9dec8395f9c..a6259267cb2 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -1666,6 +1666,10 @@ "inspector-yaml-tab": { "apply": "適用" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "IRMへの接続方法", "disabled-description": "Webhook連携を通じてIRMを有効化", @@ -1807,6 +1811,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "ブレースなしのラベルクエリを使用してアラートをフィルタします。例:", "filter-alerts-using-label-querying-without-spaces": "スペースなしのラベルクエリでアラートを絞り込みます。例:", @@ -1969,8 +1977,10 @@ "label-notification-policies": "通知ポリシー", "label-time-intervals": "時間間隔" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "デフォルトのポリシー", "no-matchers": "マッチャーなし" }, "notification-preview": { @@ -1987,22 +1997,12 @@ }, "notification-route": { "no-labels": "ラベルなし", - "no-matching-labels": "一致するラベルがありません" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "アラートインスタンスは次のようにルーティングされます。", - "close": "閉じる", - "contact-point": "コンタクトポイント", - "default-policy": "デフォルトのポリシー", - "notification-policy-path": "通知ポリシーパス", - "see-details-link": "詳細を見る", - "title-routing-details": "ルーティング詳細" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "ポリシールートを展開", - "delivered-to": "配信先:", - "notification-policy": "通知ポリシー", - "see-details": "詳細を見る" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2129,10 +2129,6 @@ "label-new-sibling-above": "上に新しい兄弟ポリシーを追加", "label-new-sibling-below": "下に新しい兄弟ポリシーを追加" }, - "policy-path": { - "default-policy": "デフォルトのポリシー", - "no-matchers": "マッチャーなし" - }, "preview-rule": { "body-preview-is-not-available": "クエリプレビューを表示できません。クエリで使用されているデータソースの一部が利用できません。", "preview-alerts": "アラートをプレビュー", @@ -9319,6 +9315,7 @@ }, "share-panel": { "config-description": "次のカスタマイズ設定を使用して、組織内でパネルを共有するためのパーソナライズされた直接リンクを作成します。", + "copy-image-link": "", "download-image": "画像をダウンロード", "render-image": "画像を生成する" } @@ -10617,6 +10614,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11289,6 +11294,7 @@ "actions": { "set-up-required-feature-toggles": "必要な機能切り替えを設定" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gitでコードとしてダッシュボードを管理し、更新を自動的にプロビジョニング", "manage-your-dashboards-with-remote-provisioning": "Git Sync を使ってみる", "store-dashboards-in-version-controlled-storage": "バージョン管理されたストレージにダッシュボードを保存して、整理と履歴追跡を改善" @@ -11476,8 +11482,16 @@ "subtitle": "外部ストレージを通じてGrafanaインスタンス全体を同期・管理したい場合は、このオプションを使用してください。" } }, - "read-only-local-tooltip": "このフォルダは読み取り専用であり、ファイルのプロビジョニングを通じてプロビジョニングされます。フォルダに変更を加えるには、接続されたファイルリポジトリを更新します。フォルダ設定を変更するには、[管理] > [プロビジョニング] > [リポジトリ]の順で移動します。", - "read-only-remote-tooltip": "このフォルダは読み取り専用であり、Gitを介してプロビジョニングされます。フォルダに変更を加えるには、接続されているリポジトリを更新します。フォルダ設定を変更するには、[管理] > [プロビジョニング] > [リポジトリ]の順に移動します。", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "アクティブなジョブ", "column-action": "操作", @@ -11591,6 +11605,7 @@ "removed-from-repo-test-ui": "リポジトリテストUIから削除されました", "updated-from-repo-test-ui": "リポジトリテストUIから更新されました" }, + "read-only-repo-tooltip": "", "repository": "レポジトリ", "tab-info": { "label": { @@ -11627,6 +11642,8 @@ "tooltip-unhealthy-repository": "問題のあるリポジトリをプルできません" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "各履歴値のコミットを含める", "synchronization-options": "同期オプション" diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 5a28fd8883c..e045a4b1c1d 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -1666,6 +1666,10 @@ "inspector-yaml-tab": { "apply": "적용" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "IRM에 연결하는 방법", "disabled-description": "Webhook 통합을 통해 IRM 활성화", @@ -1807,6 +1811,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "중괄호 없이 라벨 쿼리를 사용하여 경고를 필터링합니다. 예:", "filter-alerts-using-label-querying-without-spaces": "공백 없이 라벨 쿼리를 사용하여 경고를 필터링합니다. 예:", @@ -1969,8 +1977,10 @@ "label-notification-policies": "알림 정책", "label-time-intervals": "시간 간격" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "기본 정책", "no-matchers": "일치 조건 없음" }, "notification-preview": { @@ -1987,22 +1997,12 @@ }, "notification-route": { "no-labels": "라벨 없음", - "no-matching-labels": "일치하는 라벨 없음" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "경고 인스턴스는 다음과 같이 라우팅됩니다.", - "close": "닫기", - "contact-point": "연락처", - "default-policy": "기본 정책", - "notification-policy-path": "알림 정책 경로", - "see-details-link": "세부 정보 보기", - "title-routing-details": "라우팅 세부 정보" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "정책 라우트 펼치기", - "delivered-to": "@수신처", - "notification-policy": "알림 정책", - "see-details": "세부 정보 보기" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2129,10 +2129,6 @@ "label-new-sibling-above": "위에 형제 항목 추가", "label-new-sibling-below": "아래에 형제 항목 추가" }, - "policy-path": { - "default-policy": "기본 정책", - "no-matchers": "일치 조건 없음" - }, "preview-rule": { "body-preview-is-not-available": "쿼리 미리보기를 표시할 수 없습니다. 쿼리에 사용된 데이터 소스 중 일부를 사용할 수 없습니다.", "preview-alerts": "경고 미리 보기", @@ -9319,6 +9315,7 @@ }, "share-panel": { "config-description": "다음 사용자 지정 설정을 사용하여 조직 내에서 패널을 공유할 수 있는 개인화된 직접 링크를 생성합니다.", + "copy-image-link": "", "download-image": "이미지 다운로드", "render-image": "이미지 생성" } @@ -10617,6 +10614,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11289,6 +11294,7 @@ "actions": { "set-up-required-feature-toggles": "필수 기능 토글 설정" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Git에서 코드로 대시보드를 관리하고 업데이트를 자동으로 프로비저닝합니다", "manage-your-dashboards-with-remote-provisioning": "Git 동기화 시작하기", "store-dashboards-in-version-controlled-storage": "버전이 관리되는 스토리지에 대시보드를 저장하여 조직 및 기록 추적 기능 향상" @@ -11476,8 +11482,16 @@ "subtitle": "외부 스토리지를 통해 전체 Grafana 인스턴스를 동기화하고 관리하려면 이 옵션을 사용하세요." } }, - "read-only-local-tooltip": "이 폴더는 읽기 전용이며 파일 프로비저닝을 통해 프로비저닝됩니다. 폴더에 변경 사항을 적용하려면, 연결된 파일 리포지토리를 업데이트해야 합니다. 폴더 설정을 수정하려면 '관리 > 프로비저닝 > 리포지토리'로 이동하세요.", - "read-only-remote-tooltip": "이 폴더는 읽기 전용이며 Git을 통해 프로비저닝됩니다. 폴더에 변경 사항을 적용하려면, 연결된 파일 리포지토리를 업데이트해야 합니다. 폴더 설정을 수정하려면 관'리 > 프로비저닝 > 리포지토리'로 이동하세요.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "활성 작업", "column-action": "동작", @@ -11591,6 +11605,7 @@ "removed-from-repo-test-ui": "리포지토리 테스트 UI에서 제거됨", "updated-from-repo-test-ui": "리포지토리 테스트 UI에서 업데이트됨" }, + "read-only-repo-tooltip": "", "repository": "리포지토리", "tab-info": { "label": { @@ -11627,6 +11642,8 @@ "tooltip-unhealthy-repository": "상태가 좋지 않은 리포지토리를 가져올 수 없습니다" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "각 과거 값에 대한 커밋을 포함합니다", "synchronization-options": "동기화 옵션" diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 9a93d720c09..9c733b2e581 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Toepassen" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Verbinding maken met IRM", "disabled-description": "Schakel IRM in via een Webhook-integratie", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filter waarschuwingen met behulp van labelquery's zonder accolades, bijv.:", "filter-alerts-using-label-querying-without-spaces": "Filter waarschuwingen met behulp van labelquery's zonder spaties, bijv.:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Meldingsbeleid", "label-time-intervals": "Tijdsintervallen" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Standaardbeleid", "no-matchers": "Geen matchers" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Geen labels", - "no-matching-labels": "Geen overeenkomende labels" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Je waarschuwingsinstanties worden als volgt gerouteerd.", - "close": "Sluiten", - "contact-point": "Contactpunt", - "default-policy": "Standaardbeleid", - "notification-policy-path": "Meldingsbeleid-pad", - "see-details-link": "Bekijk details", - "title-routing-details": "Routereringsgegevens" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Beleidsroute uitvouwen", - "delivered-to": "@ Afgeleverd bij", - "notification-policy": "Meldingsbeleid", - "see-details": "Bekijk details" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Nieuwe aanverwant boven", "label-new-sibling-below": "Nieuwe aanverwant onder" }, - "policy-path": { - "default-policy": "Standaardbeleid", - "no-matchers": "Geen matchers" - }, "preview-rule": { "body-preview-is-not-available": "Kan het queryvoorbeeld niet weergeven. Sommige gegevensbronnen die in de query's worden gebruikt, zijn niet beschikbaar.", "preview-alerts": "Voorbeeldwaarschuwingen", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Maak een gepersonaliseerde, directe link om je dashboard binnen je organisatie te delen, met de volgende aanpassingsinstellingen:", + "copy-image-link": "", "download-image": "Afbeelding downloaden", "render-image": "Afbeelding genereren" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Vereiste functieschakelaars instellen" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Beheer dashboards als code in GitHub en verstrek updates automatisch", "manage-your-dashboards-with-remote-provisioning": "Aan de slag met Git-synchronisatie", "store-dashboards-in-version-controlled-storage": "Sla dashboards op in versiegecontroleerde opslag voor betere organisatie en het bijhouden van de geschiedenis" @@ -11516,8 +11522,16 @@ "subtitle": "Gebruik deze optie als je je hele Grafana-instantie via externe opslag wilt synchroniseren en beheren." } }, - "read-only-local-tooltip": "Deze map is alleen-lezen en wordt beheerd via bestandsvoorziening. Wil je iets wijzigen? Pas dan de gekoppelde bestandsrepository aan. Voor mapinstellingen ga je naar Beheer > Voorziening > Repositories.", - "read-only-remote-tooltip": "Deze map is alleen-lezen en wordt beheerd via Git. Wil je iets wijzigen? Pas dan de gekoppelde repository aan. Voor mapinstellingen ga je naar Beheer > Voorziening > Repositories.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "actieve taken", "column-action": "Actie", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "verwijderd uit repo-test-UI", "updated-from-repo-test-ui": "bijgewerkt vanuit repo-test-UI" }, + "read-only-repo-tooltip": "", "repository": "Repository", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Kan geen ongezonde repository ophalen" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Commits opnemen voor elke historische waarde", "synchronization-options": "Synchronisatie-opties" diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index cbb9646f775..3a82dd3501c 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -1681,6 +1681,10 @@ "inspector-yaml-tab": { "apply": "Zastosuj" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Jak połączyć się z usługą IRM", "disabled-description": "Włącz usługę IRM za pomocą integracji przez webhook", @@ -1825,6 +1829,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtruj alerty za pomocą zapytania o etykiety bez nawiasów klamrowych, np.:", "filter-alerts-using-label-querying-without-spaces": "Filtruj alerty za pomocą zapytania o etykiety bez spacji, na przykład:", @@ -1987,8 +1995,10 @@ "label-notification-policies": "Zasady dotyczące powiadomień", "label-time-intervals": "Odstępy czasu" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Domyślne zasady", "no-matchers": "Brak dopasowań" }, "notification-preview": { @@ -2005,22 +2015,12 @@ }, "notification-route": { "no-labels": "Brak etykiet", - "no-matching-labels": "Brak pasujących etykiet" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Instancje alertów są kierowane w następujący sposób.", - "close": "Zamknij", - "contact-point": "Punkt kontaktowy", - "default-policy": "Domyślne zasady", - "notification-policy-path": "Ścieżka zasad dotyczących powiadomień", - "see-details-link": "Zobacz szczegóły", - "title-routing-details": "Szczegóły tras" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Rozwiń trasę zasad", - "delivered-to": "@ Dostarczane do", - "notification-policy": "Zasady dotyczące powiadomień", - "see-details": "Zobacz szczegóły" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2153,10 +2153,6 @@ "label-new-sibling-above": "Nowy element pokrewny powyżej", "label-new-sibling-below": "Nowy element pokrewny poniżej" }, - "policy-path": { - "default-policy": "Domyślne zasady", - "no-matchers": "Brak dopasowań" - }, "preview-rule": { "body-preview-is-not-available": "Nie można wyświetlić podglądu zapytania. Niektóre źródła danych używane w zapytaniach są niedostępne.", "preview-alerts": "Podgląd alertów", @@ -9403,6 +9399,7 @@ }, "share-panel": { "config-description": "Utwórz spersonalizowany, bezpośredni link, aby udostępnić panel w swojej organizacji z następującymi ustawieniami dostosowywania:", + "copy-image-link": "", "download-image": "Pobierz obraz", "render-image": "Wygeneruj obraz" } @@ -10719,6 +10716,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11403,6 +11408,7 @@ "actions": { "set-up-required-feature-toggles": "Skonfiguruj wymagane przełączniki funkcji" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Zarządzaj pulpitami jako kodem w usłudze Git i automatycznie aprowizuj aktualizacje", "manage-your-dashboards-with-remote-provisioning": "Rozpocznij pracę z synchronizacją z usługą Git", "store-dashboards-in-version-controlled-storage": "Przechowuj pulpity nawigacyjne w pamięci z kontrolą wersji, aby zapewnić lepszą organizację i śledzenie historii zmian" @@ -11596,8 +11602,16 @@ "subtitle": "Użyj tej opcji, jeśli chcesz synchronizować całą instancję usługi Grafana i zarządzać nią za pośrednictwem zewnętrznej pamięci masowej." } }, - "read-only-local-tooltip": "Ten folder jest tylko do odczytu i jest udostępniany poprzez aprowizację plików. Aby wprowadzić zmiany w folderze, zaktualizuj połączone repozytorium plików. Aby zmienić ustawienia folderu, przejdź do sekcji Administracja > Udostępnianie > Repozytoria.", - "read-only-remote-tooltip": "Ten folder jest tylko do odczytu i jest udostępniany przez Git. Aby wprowadzić zmiany w folderze, zaktualizuj połączone repozytorium. Aby zmienić ustawienia folderu, przejdź do sekcji Administracja > Udostępnianie > Repozytoria.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "aktywne zadania", "column-action": "Działanie", @@ -11711,6 +11725,7 @@ "removed-from-repo-test-ui": "usunięto z interfejsu testowego repozytorium", "updated-from-repo-test-ui": "zaktualizowano z interfejsu testowego repozytorium" }, + "read-only-repo-tooltip": "", "repository": "Repozytorium", "tab-info": { "label": { @@ -11747,6 +11762,8 @@ "tooltip-unhealthy-repository": "Nie można pobrać danych z niesprawnego repozytorium" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Uwzględnij zatwierdzenia dla każdej wartości historycznej", "synchronization-options": "Opcje synchronizacji" diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index a7e59b477e2..e2970e52e34 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Aplicar" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Como se conectar ao IRM", "disabled-description": "Ativar IRM por meio de uma integração com Webhook", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtre os alertas usando a consulta por etiquetas sem colchetes, ex:", "filter-alerts-using-label-querying-without-spaces": "Filtre os alertas usando a consulta por rótulos sem espaços. Por exemplo:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Políticas de notificação", "label-time-intervals": "Intervalos de tempo" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Política padrão", "no-matchers": "Sem correspondências" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Sem rótulos", - "no-matching-labels": "Sem rótulos correspondentes" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Suas instâncias de alerta são encaminhadas da seguinte maneira.", - "close": "Fechar", - "contact-point": "Ponto de contato", - "default-policy": "Política padrão", - "notification-policy-path": "Caminho da política de notificação", - "see-details-link": "Ver detalhes", - "title-routing-details": "Detalhes do encaminhamento" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Expandir rota da política", - "delivered-to": "@ Entregue para", - "notification-policy": "Política de notificação", - "see-details": "Ver detalhes" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Novo elemento do mesmo nível acima", "label-new-sibling-below": "Novo elemento do mesmo nível abaixo" }, - "policy-path": { - "default-policy": "Política padrão", - "no-matchers": "Sem correspondências" - }, "preview-rule": { "body-preview-is-not-available": "Não é possível exibir a visualização da consulta. Algumas das fontes de dados usadas nas consultas não estão disponíveis.", "preview-alerts": "Pré-visualizar alertas", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Crie um link direto e personalizado para compartilhar seu painel dentro da sua organização com as seguintes configurações de personalização:", + "copy-image-link": "", "download-image": "Baixar imagem", "render-image": "Gerar imagem" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Configure os botões de alternância de recursos necessários" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gerencie painéis como código no Git e provisione atualizações automaticamente", "manage-your-dashboards-with-remote-provisioning": "Comece a usar o Git Sync", "store-dashboards-in-version-controlled-storage": "Armazene os painéis em um armazenamento com controle por versão para aprimorar a organização e o rastreamento de histórico" @@ -11516,8 +11522,16 @@ "subtitle": "Use esta opção se você quiser sincronizar e gerenciar toda a sua instância da Grafana por meio de armazenamento externo." } }, - "read-only-local-tooltip": "Esta pasta está definida como de somente leitura e está provisionada por meio de provisionamento de arquivos. Para realizar qualquer alteração na pasta, atualize o repositório de arquivos conectado. Para modificar as configurações da pasta, acesse Administração > Provisionamento > Repositórios.", - "read-only-remote-tooltip": "Esta pasta está definida como de somente leitura e é provisionada por meio do Git. Para realizar qualquer alteração na pasta, atualize o repositório conectado. Para modificar as configurações da pasta, acesse Administração > Provisionamento > Repositórios.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "tarefas ativas", "column-action": "Ação", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "removido da interface de teste do repositório", "updated-from-repo-test-ui": "atualizado a partir da interface de teste do repositório" }, + "read-only-repo-tooltip": "", "repository": "Repositório", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Não é possível fazer extração de um repositório instável" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Incluir confirmações para cada valor histórico", "synchronization-options": "Opções de sincronização" diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 6e2f7af299e..fcbe5db4dc6 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Aplicar" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Como se ligar à IRM", "disabled-description": "Ativar IRM através de uma integração Webhook", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtrar alertas através da utilização de consultas de etiqueta sem parênteses, ex.:", "filter-alerts-using-label-querying-without-spaces": "Filtrar alertas através da utilização de consultas de etiqueta sem espaços, ex.:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Políticas de notificação", "label-time-intervals": "Intervalos de tempo" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Política padrão", "no-matchers": "Sem correspondências" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Sem etiquetas", - "no-matching-labels": "Sem etiquetas correspondentes" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "As suas instâncias de alerta são encaminhadas da seguinte forma.", - "close": "Fechar", - "contact-point": "Ponto de contacto", - "default-policy": "Política padrão", - "notification-policy-path": "Caminho da política de notificação", - "see-details-link": "Ver detalhes", - "title-routing-details": "Detalhes do encaminhamento" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Expandir o encaminhamento da política", - "delivered-to": "@ Entregue a", - "notification-policy": "Política de notificação", - "see-details": "Ver detalhes" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Novo elemento do mesmo nível acima", "label-new-sibling-below": "Novo elemento do mesmo nível abaixo" }, - "policy-path": { - "default-policy": "Política padrão", - "no-matchers": "Sem correspondências" - }, "preview-rule": { "body-preview-is-not-available": "Não é possível exibir a pré-visualização da consulta. Algumas das origens de dados utilizadas nas consultas não estão disponíveis.", "preview-alerts": "Pré-visualizar alertas", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Crie um link direto e personalizado para partilhar o seu painel dentro da sua organização, com as seguintes definições de personalização:", + "copy-image-link": "", "download-image": "Descarregar imagem", "render-image": "Gerar imagem" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Configure as opções de funcionalidades necessárias" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Gerir painéis de controlo como código no Git e fornecer atualizações automaticamente", "manage-your-dashboards-with-remote-provisioning": "Começar a usar o Git Sync", "store-dashboards-in-version-controlled-storage": "Armazenar painéis em armazenamento controlado por versão para melhor organização e acompanhamento do histórico" @@ -11516,8 +11522,16 @@ "subtitle": "Utilize esta opção se pretender sincronizar e gerir toda a sua instância Grafana através de um armazenamento externo." } }, - "read-only-local-tooltip": "Esta pasta é apenas de leitura e aprovisionada através do aprovisionamento de ficheiros. Para fazer quaisquer alterações na pasta, atualize o repositório de ficheiros associado. Para modificar as definições da pasta, vá a Administração > Aprovisionamento > Repositórios.", - "read-only-remote-tooltip": "Esta pasta é só de leitura e aprovisionada através de Git. Para fazer quaisquer alterações na pasta, atualize o repositório associado. Para modificar as configurações da pasta, vá a Administração > Aprovisionamento > Repositórios.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "trabalhos ativos", "column-action": "Ação", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "removido da interface de utilizador de teste de repositório", "updated-from-repo-test-ui": "atualizado a partir da interface de utilizador de teste de repositório" }, + "read-only-repo-tooltip": "", "repository": "Repositório", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Não foi possível obter um repositório que não está em bom estado" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Incluir commits para cada valor histórico", "synchronization-options": "Opções de sincronização" diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 3eb55c8bdee..c52c7be6f1f 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -1681,6 +1681,10 @@ "inspector-yaml-tab": { "apply": "Применить" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Как подключиться к IRM", "disabled-description": "Включите IRM через интеграцию с веб-перехватчиком.", @@ -1825,6 +1829,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Фильтр оповещений с использованием запросов меток без скобок, например,", "filter-alerts-using-label-querying-without-spaces": "Фильтруйте оповещения с использованием запросов меток без пробелов, например,", @@ -1987,8 +1995,10 @@ "label-notification-policies": "Политики уведомления", "label-time-intervals": "Временные интервалы" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Политика по умолчанию", "no-matchers": "Нет средств сопоставления" }, "notification-preview": { @@ -2005,22 +2015,12 @@ }, "notification-route": { "no-labels": "Нет меток", - "no-matching-labels": "Нет совпадающих меток" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Ваши экземпляры оповещений направляются следующим образом.", - "close": "Закрыть", - "contact-point": "Точка контакта", - "default-policy": "Политика по умолчанию", - "notification-policy-path": "Путь политики уведомлений", - "see-details-link": "Подробнее", - "title-routing-details": "Сведения о маршрутизации" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Развернуть маршрут политики", - "delivered-to": "@ Кому доставлено", - "notification-policy": "Политика уведомления", - "see-details": "Подробнее" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2153,10 +2153,6 @@ "label-new-sibling-above": "Новый элемент того же уровня выше", "label-new-sibling-below": "Новый элемент того же уровня ниже" }, - "policy-path": { - "default-policy": "Политика по умолчанию", - "no-matchers": "Нет средств сопоставления" - }, "preview-rule": { "body-preview-is-not-available": "Невозможно отобразить предварительный просмотр запроса. Некоторые источники данных, используемые в запросах, недоступны.", "preview-alerts": "Просмотр оповещений", @@ -9403,6 +9399,7 @@ }, "share-panel": { "config-description": "Создайте индивидуальную прямую ссылку, чтобы делиться своей панелью внутри организации, с помощью следующих параметров настройки:", + "copy-image-link": "", "download-image": "Загрузить изображение", "render-image": "Создать изображение" } @@ -10719,6 +10716,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11403,6 +11408,7 @@ "actions": { "set-up-required-feature-toggles": "Настроить необходимые переключатели функциональности" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Управлять дашбордами как кодом в Git и автоматически выполнять обновления", "manage-your-dashboards-with-remote-provisioning": "Начало работы с Git Sync", "store-dashboards-in-version-controlled-storage": "Храните дашборды в хранилище с контролем версий для лучшей организации и отслеживания журналов" @@ -11596,8 +11602,16 @@ "subtitle": "Используйте этот параметр, если хотите синхронизировать весь экземпляр Grafana и управлять им через внешнее хранилище" } }, - "read-only-local-tooltip": "Эта папка предназначена только для чтения и предоставлена в рамках подготовки файлов. Чтобы внести изменения, обновите подключенный файловый репозиторий. Отредактировать настройки папки можно в разделе «Администрирование > Подготовка > Репозитории».", - "read-only-remote-tooltip": "Эта папка доступна только для чтения и предоставлена в формате GIT. Чтобы внести изменения, обновите подключенный репозиторий. Отредактировать настройки папке можно в разделе «Администрирование > Подготовка > Репозитории».", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "активные задания", "column-action": "Действие", @@ -11711,6 +11725,7 @@ "removed-from-repo-test-ui": "удалено из тестового пользовательского интерфейса репозитория", "updated-from-repo-test-ui": "обновлено из тестового пользовательского интерфейса репозитория" }, + "read-only-repo-tooltip": "", "repository": "Репозиторий", "tab-info": { "label": { @@ -11747,6 +11762,8 @@ "tooltip-unhealthy-repository": "Невозможно внести изменения в неисправный репозиторий" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Включить фиксации для каждого ранее зафиксированного значения", "synchronization-options": "Параметры синхронизации" diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 9a11f9f0380..32151f1c8be 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Tillämpa" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "Så här ansluter du till IRM", "disabled-description": "Aktivera IRM via en Webhook-integration", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Filtrera varningar med etikettförfrågan utan klamrar, t.ex.:", "filter-alerts-using-label-querying-without-spaces": "Filtrera larm med etikettförfrågan utan mellanslag, t.ex.:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Aviseringspolicyer", "label-time-intervals": "Tidsintervall" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Standardpolicy", "no-matchers": "Inga matchare" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Inga etiketter", - "no-matching-labels": "Inga matchande etiketter" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Dina larminstanser dirigeras enligt följande.", - "close": "Stäng", - "contact-point": "Kontaktpunkt", - "default-policy": "Standardpolicy", - "notification-policy-path": "Sökväg för aviseringspolicy", - "see-details-link": "Se detaljer", - "title-routing-details": "Routningsdetaljer" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Visa policyväg", - "delivered-to": "@ Levereras till", - "notification-policy": "Aviseringspolicy", - "see-details": "Se detaljer" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Nytt syskon ovanför", "label-new-sibling-below": "Nytt syskon nedanför" }, - "policy-path": { - "default-policy": "Standardpolicy", - "no-matchers": "Inga matchare" - }, "preview-rule": { "body-preview-is-not-available": "Det går inte att visa förhandsgranskningen av frågan. Vissa av de datakällor som används i frågorna är inte tillgängliga.", "preview-alerts": "Förhandsgranska varningar", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Skapa en personlig direktlänk för att dela din panel inom din organisation, med följande anpassningsinställningar:", + "copy-image-link": "", "download-image": "Ladda ner bild", "render-image": "Generera bild" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Konfigurera obligatoriska funktionsväxlare" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Hantera kontrollpaneler som kod i Git och provisionering av uppdateringar automatiskt", "manage-your-dashboards-with-remote-provisioning": "Kom igång med Git Sync", "store-dashboards-in-version-controlled-storage": "Lagra instrumentpaneler med versionsstyrd lagring för bättre organisering och historikspårning" @@ -11516,8 +11522,16 @@ "subtitle": "Använd det här alternativet om du vill synkronisera och hantera hela din Grafana-instans via extern lagring." } }, - "read-only-local-tooltip": "Denna mapp är skrivskyddad och provisionerad via filprovisionering. Om du behöver göra ändringar i mappen måste du uppdatera den anslutna fillagringsplatsen. Ändra mappinställningarna genom att gå till Administration > Provisionering > Lagringsplatser.", - "read-only-remote-tooltip": "Den här mappen är skrivskyddad och provisionerad via Git. Om du behöver göra ändringar i mappen måste du uppdatera det anslutna arkivet. Ändra mappinställningarna genom att gå till Administration > Provisionering > Lagringsplatser.", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "aktiva jobb", "column-action": "Åtgärd", @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "borttagen från arkivets testgränssnitt", "updated-from-repo-test-ui": "uppdaterat från arkivets testgränssnitt" }, + "read-only-repo-tooltip": "", "repository": "Databas", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "Det gick inte att hämta en ohälsosam lagringsplats" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Inkludera åtaganden för varje historiskt värde", "synchronization-options": "Synkroniseringsalternativ" diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 145f42308c5..2f9ebc15e0b 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -1671,6 +1671,10 @@ "inspector-yaml-tab": { "apply": "Uygula" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "IRM'ye bağlanma", "disabled-description": "Bir Web kancası entegrasyonu aracılığıyla IRM'yi etkinleştirin", @@ -1813,6 +1817,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "Uyarıları parantezler olmadan etiket sorgulama ile filtreleyin, örneğin:", "filter-alerts-using-label-querying-without-spaces": "Etiket sorgulaması kullanarak boşluksuz uyarı filtresi oluşturun, örn.:", @@ -1975,8 +1983,10 @@ "label-notification-policies": "Bildirim Politikaları", "label-time-intervals": "Zaman aralıkları" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "Varsayılan politika", "no-matchers": "Eşleşen yok" }, "notification-preview": { @@ -1993,22 +2003,12 @@ }, "notification-route": { "no-labels": "Etiket yok", - "no-matching-labels": "Eşleşen etiket yok" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "Uyarı örnekleriniz aşağıdaki şekilde yönlendiriliyor.", - "close": "Kapat", - "contact-point": "İletişim noktası", - "default-policy": "Varsayılan politika", - "notification-policy-path": "Bildirim politikası yolu", - "see-details-link": "Ayrıntıları görüntüle", - "title-routing-details": "Yönlendirme ayrıntıları" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "Politika yönlendirmesini genişlet", - "delivered-to": "@ Teslim alan:", - "notification-policy": "Bildirim politikası", - "see-details": "Ayrıntıları görüntüle" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2137,10 +2137,6 @@ "label-new-sibling-above": "Yukarıdan yeni eş düzey", "label-new-sibling-below": "Aşağıdan yeni eş düzey" }, - "policy-path": { - "default-policy": "Varsayılan politika", - "no-matchers": "Eşleşen yok" - }, "preview-rule": { "body-preview-is-not-available": "Sorgu ön izlemesi görüntülenemiyor. Sorgularda kullanılan bazı veri kaynakları mevcut değil.", "preview-alerts": "Uyarıları ön izle", @@ -9347,6 +9343,7 @@ }, "share-panel": { "config-description": "Panelinizi kuruluşunuz içinde paylaşmak için kişiselleştirilmiş, doğrudan bir bağlantı oluşturun. ", + "copy-image-link": "", "download-image": "Görseli indir", "render-image": "Görsel oluştur" } @@ -10651,6 +10648,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11327,6 +11332,7 @@ "actions": { "set-up-required-feature-toggles": "Gerekli özellik anahtarlarını ayarla" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "Panoları Git'de kod olarak yönetin ve güncellemeleri otomatik sağlayın", "manage-your-dashboards-with-remote-provisioning": "Git Sync'i kullanmaya başlayın", "store-dashboards-in-version-controlled-storage": "Daha iyi organizasyon ve geçmiş takibi için panoları sürüm kontrollü bir depoda saklayın" @@ -11516,6 +11522,14 @@ "subtitle": "Tüm Grafana örneğinizi haricî depolama üzerinden senkronize edip yönetmek istiyorsanız bu seçeneği kullanın." } }, + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { @@ -11631,6 +11645,7 @@ "removed-from-repo-test-ui": "depo test arayüzünden kaldırıldı", "updated-from-repo-test-ui": "depo test arayüzünden güncellendi" }, + "read-only-repo-tooltip": "", "repository": "Depo", "tab-info": { "label": { @@ -11667,6 +11682,8 @@ "tooltip-unhealthy-repository": "İyi durumda olmayan bir depo çekilemedi" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "Her geçmiş değer için yürütmeyi dahil et", "synchronization-options": "Senkronizasyon seçenekleri" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index e92a03eb587..a811f038adf 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1666,6 +1666,10 @@ "inspector-yaml-tab": { "apply": "应用" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "如何连接到 IRM", "disabled-description": "通过 Webhook 集成启用 IRM", @@ -1807,6 +1811,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "使用不带大括号的标签查询来筛选提醒,例如:", "filter-alerts-using-label-querying-without-spaces": "使用不带空格的标签查询来筛选警报,例如:", @@ -1969,8 +1977,10 @@ "label-notification-policies": "通知策略", "label-time-intervals": "时间间隔" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "默认策略", "no-matchers": "没有匹配器" }, "notification-preview": { @@ -1987,22 +1997,12 @@ }, "notification-route": { "no-labels": "无标签", - "no-matching-labels": "无匹配标签" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "您的警报实例的路由如下。", - "close": "关闭", - "contact-point": "联络点", - "default-policy": "默认策略", - "notification-policy-path": "通知策略路径", - "see-details-link": "查看详情", - "title-routing-details": "路由详情" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "展开策略路由", - "delivered-to": "@ 发送目标", - "notification-policy": "通知策略", - "see-details": "查看详情" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2129,10 +2129,6 @@ "label-new-sibling-above": "上方的新同级元素", "label-new-sibling-below": "下方的新同级元素" }, - "policy-path": { - "default-policy": "默认策略", - "no-matchers": "没有匹配器" - }, "preview-rule": { "body-preview-is-not-available": "无法显示查询预览。查询中使用的某些数据源不可用。", "preview-alerts": "预览警报", @@ -9319,6 +9315,7 @@ }, "share-panel": { "config-description": "使用以下自定义设置,创建一个个性化的直接链接,以便在您的组织内共享面板:", + "copy-image-link": "", "download-image": "下载图像", "render-image": "生成图像" } @@ -10617,6 +10614,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11289,6 +11294,7 @@ "actions": { "set-up-required-feature-toggles": "设置所需的功能切换" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "在 Git 中将数据面板作为代码进行管理,并自动预置更新", "manage-your-dashboards-with-remote-provisioning": "开始使用 Git 同步", "store-dashboards-in-version-controlled-storage": "将数据面板存储在版本控制的存储中,以便更好地整理内容和跟踪历史记录" @@ -11476,8 +11482,16 @@ "subtitle": "如果要通过外部存储来同步和管理整个 Grafana 实例,请使用此选项。" } }, - "read-only-local-tooltip": "此文件夹是只读的,并已通过文件预置操作进行预置。要对文件夹进行任何更改,请更新连接的文件存储库。要修改文件夹设置,请依次转到“管理”>“预置”>“存储库”。", - "read-only-remote-tooltip": "此文件夹是只读的,并已通过 Git 预置。要对文件夹进行任何更改,请更新连接的存储库。要修改文件夹设置,请依次转到“管理”>“预置”>“存储库”。", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "活跃的作业", "column-action": "操作", @@ -11591,6 +11605,7 @@ "removed-from-repo-test-ui": "已从存储库测试用户界面中移除", "updated-from-repo-test-ui": "已从存储库测试用户界面中更新" }, + "read-only-repo-tooltip": "", "repository": "存储库", "tab-info": { "label": { @@ -11627,6 +11642,8 @@ "tooltip-unhealthy-repository": "无法拉取状态不良的存储库" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "包括每个历史值的提交", "synchronization-options": "同步选项" diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 90abf1e3117..1779c029c0c 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -1666,6 +1666,10 @@ "inspector-yaml-tab": { "apply": "套用" }, + "instance-match": { + "non-matching-labels": "", + "notification-policy": "" + }, "irm-integration": { "connection-method": "如何連接到 IRM", "disabled-description": "透過 Webhook 整合啟用 IRM", @@ -1807,6 +1811,10 @@ } } }, + "match-details": { + "matched": "", + "no-matchers-matched": "" + }, "matcher-filter": { "filter-alerts-using-label-querying-without-braces": "使用不含大括號的標籤查詢來篩選警報,例如:", "filter-alerts-using-label-querying-without-spaces": "使用不含空格的標籤查詢來篩選警報,例如:", @@ -1969,8 +1977,10 @@ "label-notification-policies": "通知政策", "label-time-intervals": "時間間隔" }, + "notification-policy-drawer": { + "view-notification-policy-tree": "" + }, "notification-policy-matchers": { - "default-policy": "預設政策", "no-matchers": "沒有匹配條件" }, "notification-preview": { @@ -1987,22 +1997,12 @@ }, "notification-route": { "no-labels": "沒有標籤", - "no-matching-labels": "沒有匹配的標籤" - }, - "notification-route-details-modal": { - "alert-instances-routed-follows": "您的警報執行個體依以下方式傳送。", - "close": "關閉", - "contact-point": "聯絡點", - "default-policy": "預設政策", - "notification-policy-path": "通知政策路徑", - "see-details-link": "查看詳情", - "title-routing-details": "路由詳細資料" + "notification-policy": "" }, "notification-route-header": { "aria-label-expand-policy-route": "展開政策路由", - "delivered-to": "@ 已傳送至", - "notification-policy": "通知政策", - "see-details": "查看詳情" + "delivered-to": "", + "instances": "" }, "notification-templates": { "duplicate": { @@ -2129,10 +2129,6 @@ "label-new-sibling-above": "上方的新同級項目", "label-new-sibling-below": "下方的新同級項目" }, - "policy-path": { - "default-policy": "預設政策", - "no-matchers": "沒有匹配條件" - }, "preview-rule": { "body-preview-is-not-available": "無法顯示查詢預覽。查詢中使用的某些資料來源不可用。", "preview-alerts": "預覽警報", @@ -9319,6 +9315,7 @@ }, "share-panel": { "config-description": "使用以下自訂設定,建立個人化的直接連結,以便在組織內分享面板:", + "copy-image-link": "", "download-image": "下載圖片", "render-image": "產生圖片" } @@ -10617,6 +10614,14 @@ } }, "panel": { + "dashlist": { + "empty-state-message": "", + "mark-as-starred": "", + "recently-viewed-dashboards": "", + "search": "", + "starred-dashboards": "", + "unmark-as-starred": "" + }, "get-calculation-value-data-links-variable-suggestions": { "value-calc-var": { "label": { @@ -11289,6 +11294,7 @@ "actions": { "set-up-required-feature-toggles": "設定所需的功能切換" }, + "learn-more-documentation": "", "manage-dashboards-provision-updates-automatically": "在 Git 中將儀表板作為代碼管理,並自動佈建更新", "manage-your-dashboards-with-remote-provisioning": "開始使用 Git Sync", "store-dashboards-in-version-controlled-storage": "將儀表板儲存在版本控制的儲存空間中,以便更好地組織和追蹤歷史紀錄" @@ -11476,8 +11482,16 @@ "subtitle": "若想透過外部儲存空間同步及管理整個 Grafana 執行個體,請使用此選項。" } }, - "read-only-local-tooltip": "此資料夾為唯讀,並透過檔案佈建進行佈建。若要對資料夾進行任何變更,請更新連線的檔案儲存庫。如要修改資料夾設定,請依序前往「管理」>「佈建」>「儲存庫」。", - "read-only-remote-tooltip": "此資料夾為唯讀,並透過 Git 進行佈建。如要對資料夾進行任何變更,請更新連線的儲存庫。若要修改資料夾設定,請依序前往「管理」>「佈建」>「儲存庫」。", + "provisioning-wizard": { + "on-submit": { + "title": { + "repository-connection-failed": "", + "repository-request-failed": "" + } + } + }, + "read-only-local-tooltip": "", + "read-only-remote-tooltip": "", "recent-jobs": { "active-jobs": "進行中的作業", "column-action": "動作", @@ -11591,6 +11605,7 @@ "removed-from-repo-test-ui": "已從資源庫測試使用者介面移除", "updated-from-repo-test-ui": "已從資源庫測試使用者介面更新" }, + "read-only-repo-tooltip": "", "repository": "存放庫", "tab-info": { "label": { @@ -11627,6 +11642,8 @@ "tooltip-unhealthy-repository": "無法拉取狀態不佳的儲存庫" }, "synchronize-step": { + "repository-error": "", + "repository-error-message": "", "repository-unhealthy": "", "synchronization-description": "包含每個歷史數值的提交", "synchronization-options": "同步選項" From 47e6528c74160da8c73df065b9e34a3443bd5cba Mon Sep 17 00:00:00 2001 From: Andrew Hackmann <5140848+bossinc@users.noreply.github.com> Date: Fri, 26 Sep 2025 04:14:58 -0500 Subject: [PATCH 12/31] Docs: Fix link reference in Generic OAuth documentation (#111647) Corrected the link reference for examples of setting up Generic OAuth. --- .../configure-authentication/generic-oauth/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index f28dc35b7f6..20d08963191 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -33,7 +33,7 @@ Grafana provides OAuth2 integrations for the following auth providers: If your OAuth2 provider is not listed, you can use Generic OAuth authentication. -This topic describes how to configure Generic OAuth authentication using different methods and includes [examples of setting up Generic OAuth](#examples-of-setting-up-generic-oauth2) with specific OAuth2 providers. +This topic describes how to configure Generic OAuth authentication using different methods and includes [examples of setting up Generic OAuth](#examples-of-setting-up-generic-oauth) with specific OAuth2 providers. ## Before you begin From 60fed679c4d8e061e4e8b4b9ccfd88dcd5be1451 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:52:28 +0200 Subject: [PATCH 13/31] Scopes: Add filterNode and toggleExpandNode to ScopesSelectorService (#111553) Add filterNode and toggleExpandNode to ScopesSelectorService --- .../commandPalette/actions/scopesUtils.ts | 3 +- .../scopes/selector/ScopesSelector.tsx | 6 +- .../scopes/selector/ScopesSelectorService.ts | 74 ++++++++++++++++--- .../features/scopes/selector/ScopesTree.tsx | 17 +++-- .../scopes/selector/ScopesTreeItem.tsx | 11 ++- .../scopes/selector/ScopesTreeItemList.tsx | 9 ++- .../scopes/selector/ScopesTreeSearch.tsx | 6 +- .../scopes/selector/useScopesHighlighting.tsx | 6 +- 8 files changed, 101 insertions(+), 31 deletions(-) diff --git a/public/app/features/commandPalette/actions/scopesUtils.ts b/public/app/features/commandPalette/actions/scopesUtils.ts index 1a24cb47620..65c27a0c0c1 100644 --- a/public/app/features/commandPalette/actions/scopesUtils.ts +++ b/public/app/features/commandPalette/actions/scopesUtils.ts @@ -31,7 +31,7 @@ export function useScopeServicesState() { }, }; } - const { updateNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } = + const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } = services.scopesSelectorService; const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( services.scopesSelectorService.stateObservable ?? new Observable(), @@ -39,6 +39,7 @@ export function useScopeServicesState() { ); return { + filterNode, updateNode, selectScope, resetSelection, diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index 1e06753bcd7..60444eac90a 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -65,10 +65,11 @@ export const ScopesSelector = () => { removeAllScopes, closeAndApply, closeAndReset, - updateNode, + filterNode, selectScope, deselectScope, getRecentScopes, + toggleExpandedNode, } = scopesSelectorService; const recentScopes = getRecentScopes(); @@ -128,12 +129,13 @@ export const ScopesSelector = () => { { scopesSelectorService.changeScopes(scopeIds, parentNodeId); scopesSelectorService.closeAndReset(); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 2a70dca5fde..e3a504faf56 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -99,6 +99,61 @@ export class ScopesSelectorService extends ScopesServiceBase { + const path = getPathOfNode(scopeNodeId, this.state.nodes); + const nodeToToggle = treeNodeAtPath(this.state.tree!, path); + + if (!nodeToToggle) { + throw new Error(`Node ${scopeNodeId} not found in tree`); + } + + if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) { + throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`); + } + + // Collapse if expanded + if (nodeToToggle.expanded) { + const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { + treeNode.expanded = false; + // Resets query when collapsing + treeNode.query = ''; + }); + this.updateState({ tree: newTree }); + return; + } + + // Expand if collapsed + const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { + treeNode.expanded = true; + treeNode.query = ''; + }); + this.updateState({ tree: newTree }); + + await this.loadNodeChildren(path, nodeToToggle); + }; + + public filterNode = async (scopeNodeId: string, query: string) => { + const path = getPathOfNode(scopeNodeId, this.state.nodes); + const nodeToFilter = treeNodeAtPath(this.state.tree!, path); + + if (!nodeToFilter) { + throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`); + } + + if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) { + throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`); + } + + const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { + treeNode.expanded = true; + treeNode.query = query; + }); + this.updateState({ tree: newTree }); + + await this.loadNodeChildren(path, nodeToFilter, query); + }; + private expandOrFilterNode = async (scopeNodeId: string, query?: string) => { this.interactionProfiler?.startInteraction('scopeNodeDiscovery'); @@ -135,15 +190,16 @@ export class ScopesSelectorService extends ScopesServiceBase { - treeNode.expanded = false; - treeNode.query = ''; - }); - this.updateState({ tree: newTree }); - } else { + + if (!nodeToCollapse) { throw new Error(`Trying to collapse node at path or id ${scopeNodeId} not found`); } + + const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { + treeNode.expanded = false; + treeNode.query = ''; + }); + this.updateState({ tree: newTree }); }; private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => { @@ -165,7 +221,7 @@ export class ScopesSelectorService extends ScopesServiceBase { if (expanded) { diff --git a/public/app/features/scopes/selector/ScopesTree.tsx b/public/app/features/scopes/selector/ScopesTree.tsx index a38bc60f13a..928827b3d32 100644 --- a/public/app/features/scopes/selector/ScopesTree.tsx +++ b/public/app/features/scopes/selector/ScopesTree.tsx @@ -18,7 +18,7 @@ export interface ScopesTreeProps { selectedScopes: SelectedScope[]; scopeNodes: NodesMap; - onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; + filterNode: (scopeNodeId: string, query: string) => void; selectScope: (scopeNodeId: string) => void; deselectScope: (scopeNodeId: string) => void; @@ -26,6 +26,8 @@ export interface ScopesTreeProps { // Recent scopes are only shown at the root node recentScopes?: Scope[][]; onRecentScopesSelect?: (scopeIds: string[], parentNodeId?: string) => void; + + toggleExpandedNode: (scopeNodeId: string) => void; } export function ScopesTree({ @@ -34,10 +36,11 @@ export function ScopesTree({ selectedScopes, recentScopes, onRecentScopesSelect, - onNodeUpdate, + filterNode, scopeNodes, selectScope, deselectScope, + toggleExpandedNode, }: ScopesTreeProps) { const styles = useStyles2(getStyles); @@ -75,7 +78,7 @@ export function ScopesTree({ treeQuery: tree.query, scopeNodes, selectedScopes, - onNodeUpdate, + toggleExpandedNode, selectScope, deselectScope, }); @@ -91,7 +94,7 @@ export function ScopesTree({ void; + filterNode: (scopeNodeId: string, query: string) => void; selectScope: (scopeNodeId: string) => void; deselectScope: (scopeNodeId: string) => void; + toggleExpandedNode: (scopeNodeId: string) => void; } export function ScopesTreeItem({ anyChildExpanded, loadingNodeName, treeNode, - onNodeUpdate, + filterNode, scopeNodes, selected, selectedScopes, selectScope, deselectScope, highlighted, + toggleExpandedNode, }: ScopesTreeItemProps) { const styles = useStyles2(getStyles); @@ -126,7 +128,7 @@ export function ScopesTreeItem({ data-testid={`scopes-tree-${treeNode.scopeNodeId}-expand`} aria-label={treeNode.expanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')} onClick={() => { - onNodeUpdate(treeNode.scopeNodeId, !treeNode.expanded, treeNode.query); + toggleExpandedNode(treeNode.scopeNodeId); }} > @@ -145,11 +147,12 @@ export function ScopesTreeItem({ )} diff --git a/public/app/features/scopes/selector/ScopesTreeItemList.tsx b/public/app/features/scopes/selector/ScopesTreeItemList.tsx index 5560ad61803..33d3a4e2b53 100644 --- a/public/app/features/scopes/selector/ScopesTreeItemList.tsx +++ b/public/app/features/scopes/selector/ScopesTreeItemList.tsx @@ -15,11 +15,12 @@ type Props = { maxHeight: string; selectedScopes: SelectedScope[]; scopeNodes: NodesMap; - onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; + filterNode: (scopeNodeId: string, query: string) => void; selectScope: (scopeNodeId: string) => void; deselectScope: (scopeNodeId: string) => void; highlightedId: string | undefined; id: string; + toggleExpandedNode: (scopeNodeId: string) => void; }; export function ScopesTreeItemList({ @@ -30,11 +31,12 @@ export function ScopesTreeItemList({ selectedScopes, scopeNodes, loadingNodeName, - onNodeUpdate, + filterNode, selectScope, deselectScope, highlightedId, id, + toggleExpandedNode, }: Props) { const styles = useStyles2(getStyles); @@ -65,10 +67,11 @@ export function ScopesTreeItemList({ scopeNodes={scopeNodes} loadingNodeName={loadingNodeName} anyChildExpanded={anyChildExpanded} - onNodeUpdate={onNodeUpdate} + filterNode={filterNode} selectScope={selectScope} deselectScope={deselectScope} highlighted={childNode.scopeNodeId === highlightedId} + toggleExpandedNode={toggleExpandedNode} /> ); })} diff --git a/public/app/features/scopes/selector/ScopesTreeSearch.tsx b/public/app/features/scopes/selector/ScopesTreeSearch.tsx index 755d605c783..780165f6074 100644 --- a/public/app/features/scopes/selector/ScopesTreeSearch.tsx +++ b/public/app/features/scopes/selector/ScopesTreeSearch.tsx @@ -12,7 +12,7 @@ export interface ScopesTreeSearchProps { anyChildExpanded: boolean; searchArea: string; treeNode: TreeNode; - onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; + filterNode: (scopeNodeId: string, query: string) => void; onFocus: () => void; onBlur: () => void; 'aria-controls': string; @@ -22,7 +22,7 @@ export interface ScopesTreeSearchProps { export function ScopesTreeSearch({ anyChildExpanded, treeNode, - onNodeUpdate, + filterNode, searchArea, onFocus, onBlur, @@ -45,7 +45,7 @@ export function ScopesTreeSearch({ useDebounce( () => { if (inputState.dirty) { - onNodeUpdate(treeNode.scopeNodeId, true, inputState.value); + filterNode(treeNode.scopeNodeId, inputState.value); } }, 500, diff --git a/public/app/features/scopes/selector/useScopesHighlighting.tsx b/public/app/features/scopes/selector/useScopesHighlighting.tsx index 2cf727ade60..75f573a3422 100644 --- a/public/app/features/scopes/selector/useScopesHighlighting.tsx +++ b/public/app/features/scopes/selector/useScopesHighlighting.tsx @@ -11,7 +11,7 @@ interface UseScopesHighlightingParams { treeQuery: string; scopeNodes: NodesMap; selectedScopes: SelectedScope[]; - onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; + toggleExpandedNode: (scopeNodeId: string) => void; selectScope: (scopeNodeId: string) => void; deselectScope: (scopeNodeId: string) => void; } @@ -22,7 +22,7 @@ export function useScopesHighlighting({ treeQuery, scopeNodes, selectedScopes, - onNodeUpdate, + toggleExpandedNode, selectScope, deselectScope, }: UseScopesHighlightingParams) { @@ -47,7 +47,7 @@ export function useScopesHighlighting({ isNodeExpandable(scopeNodes[nodeId]); if (isExpanding || isSelectingAndExpandable) { - onNodeUpdate(nodeId, true, treeQuery); + toggleExpandedNode(nodeId); setHighlightEnabled(false); return; } From eb16fa16420097a63c0bb0bf371ea10e8ded8265 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:02:45 +0200 Subject: [PATCH 14/31] Scopes: Generate new field for default parent node (#108666) * Generate new field for default parent node * Update ScopeSpec with defaultPath and fix list types. Remove unused field * Update frontend types * Fix some errors * ScopesSelector: Update zod types for spec schema * Promlib: Update Scopes schema --------- Co-authored-by: Matheus Macabu --- packages/grafana-data/src/types/scopes.ts | 8 +++--- .../src/language_provider.ts | 4 ++- .../apis/common/v0alpha1/types.go | 11 ++++++-- .../common/v0alpha1/zz_generated.deepcopy.go | 5 ++++ .../common/v0alpha1/zz_generated.openapi.go | 28 +++++++++++++++---- ...enerated.openapi_violation_exceptions.list | 1 - pkg/promlib/models/query.go | 6 ++-- pkg/promlib/models/query.panel.schema.json | 19 ++++--------- pkg/promlib/models/query.request.schema.json | 19 ++++--------- pkg/promlib/models/query.types.json | 21 +++++--------- .../selector/ScopesSelectorService.test.ts | 24 ---------------- public/app/features/scopes/selector/types.ts | 6 ++-- .../app/features/scopes/tests/utils/mocks.ts | 24 ---------------- public/app/features/scopes/utils.ts | 3 -- 14 files changed, 66 insertions(+), 113 deletions(-) diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index b90a0e9feb2..e3321e2a8db 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -49,10 +49,10 @@ export interface ScopeSpecFilter { export interface ScopeSpec { title: string; - type: string; - description: string; - category: string; - filters: ScopeSpecFilter[]; + // Used to display the title next to the selected scope and expand the selector to the proper path. + // This will override whichever is selected from in the selector. + defaultPath?: string[]; + filters?: ScopeSpecFilter[]; } // TODO: Use Resource from apiserver when we export the types diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index 0419295ab16..568acddba64 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -488,7 +488,9 @@ export default class PromQlLanguageProvider extends LanguageProvider implements }) ), scopes: scopes?.reduce((acc, scope) => { - acc.push(...scope.spec.filters); + if (scope.spec.filters) { + acc.push(...scope.spec.filters); + } return acc; }, []), diff --git a/pkg/apimachinery/apis/common/v0alpha1/types.go b/pkg/apimachinery/apis/common/v0alpha1/types.go index 34b21adaa3e..94f5670a89a 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/types.go +++ b/pkg/apimachinery/apis/common/v0alpha1/types.go @@ -52,17 +52,22 @@ type Scope struct { } type ScopeSpec struct { - Title string `json:"title"` - Description string `json:"description"` + Title string `json:"title"` + // Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. + // This will override whichever is selected from in the selector. + // The path is a list of node ids, starting at the direct parent of the selected node towards the root. + // +listType=atomic + DefaultPath []string `json:"defaultPath,omitempty"` // +listType=atomic - Filters []ScopeFilter `json:"filters"` + Filters []ScopeFilter `json:"filters,omitempty"` } type ScopeFilter struct { Key string `json:"key"` Value string `json:"value"` // Values is used for operators that require multiple values (e.g. one-of and not-one-of). + // +listType=atomic Values []string `json:"values,omitempty"` Operator FilterOperator `json:"operator"` } diff --git a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.deepcopy.go b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.deepcopy.go index d3f8e64dbff..41c6e53441b 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.deepcopy.go @@ -94,6 +94,11 @@ func (in *ScopeFilter) DeepCopy() *ScopeFilter { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) { *out = *in + if in.DefaultPath != nil { + in, out := &in.DefaultPath, &out.DefaultPath + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Filters != nil { in, out := &in.Filters, &out.Filters *out = make([]ScopeFilter, len(*in)) diff --git a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go index 34424e88e3d..b3042736e0c 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go @@ -200,6 +200,11 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeFilter(ref common.ReferenceCa }, }, "values": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, SchemaProps: spec.SchemaProps{ Description: "Values is used for operators that require multiple values (e.g. one-of and not-one-of).", Type: []string{"array"}, @@ -243,11 +248,24 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeSpec(ref common.ReferenceCall Format: "", }, }, - "description": { + "defaultPath": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", + Description: "Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. This will override whichever is selected from in the selector. The path is a list of node ids, starting at the direct parent of the selected node towards the root.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, }, }, "filters": { @@ -269,7 +287,7 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeSpec(ref common.ReferenceCall }, }, }, - Required: []string{"title", "description", "filters"}, + Required: []string{"title"}, }, }, Dependencies: []string{ diff --git a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list index e4491e5ef86..caca0df1147 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,4 +1,3 @@ -API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1,ScopeFilter,Values API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1,Unstructured,Object API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,APIResourceList,APIResources API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,Duration,Duration diff --git a/pkg/promlib/models/query.go b/pkg/promlib/models/query.go index c54e51fea41..ae56d929ec2 100644 --- a/pkg/promlib/models/query.go +++ b/pkg/promlib/models/query.go @@ -80,10 +80,8 @@ type PrometheusQueryProperties struct { type ScopeSpec struct { Name string `json:"name"` // This is the identifier from metadata.name of the scope model. Title string `json:"title"` - Type string `json:"type"` - Description string `json:"description"` - Category string `json:"category"` - Filters []ScopeFilter `json:"filters"` + DefaultPath []string `json:"defaultPath,omitempty"` + Filters []ScopeFilter `json:"filters,omitempty"` } // ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go diff --git a/pkg/promlib/models/query.panel.schema.json b/pkg/promlib/models/query.panel.schema.json index 12f985e9292..c6ca6a172b8 100644 --- a/pkg/promlib/models/query.panel.schema.json +++ b/pkg/promlib/models/query.panel.schema.json @@ -188,18 +188,14 @@ "type": "object", "required": [ "name", - "title", - "type", - "description", - "category", - "filters" + "title" ], "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" + "defaultPath": { + "type": "array", + "items": { + "type": "string" + } }, "filters": { "type": "array", @@ -238,9 +234,6 @@ }, "title": { "type": "string" - }, - "type": { - "type": "string" } }, "additionalProperties": false diff --git a/pkg/promlib/models/query.request.schema.json b/pkg/promlib/models/query.request.schema.json index c8279deeb37..0e533b8e5c7 100644 --- a/pkg/promlib/models/query.request.schema.json +++ b/pkg/promlib/models/query.request.schema.json @@ -198,18 +198,14 @@ "type": "object", "required": [ "name", - "title", - "type", - "description", - "category", - "filters" + "title" ], "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" + "defaultPath": { + "type": "array", + "items": { + "type": "string" + } }, "filters": { "type": "array", @@ -248,9 +244,6 @@ }, "title": { "type": "string" - }, - "type": { - "type": "string" } }, "additionalProperties": false diff --git a/pkg/promlib/models/query.types.json b/pkg/promlib/models/query.types.json index 052d0998eac..6d29e934973 100644 --- a/pkg/promlib/models/query.types.json +++ b/pkg/promlib/models/query.types.json @@ -8,7 +8,7 @@ { "metadata": { "name": "default", - "resourceVersion": "1725885733879", + "resourceVersion": "1758739325095", "creationTimestamp": "2024-03-25T13:19:04Z" }, "spec": { @@ -105,11 +105,11 @@ "additionalProperties": false, "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix).", "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" + "defaultPath": { + "items": { + "type": "string" + }, + "type": "array" }, "filters": { "items": { @@ -148,18 +148,11 @@ }, "title": { "type": "string" - }, - "type": { - "type": "string" } }, "required": [ "name", - "title", - "type", - "description", - "category", - "filters" + "title" ], "type": "object" }, diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index d6f8c2f09bf..5721d1daaf8 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -17,9 +17,6 @@ describe('ScopesSelectorService', () => { }, spec: { title: 'test-scope', - type: 'scope', - description: 'test scope', - category: 'scope', filters: [], }, }; @@ -30,9 +27,6 @@ describe('ScopesSelectorService', () => { }, spec: { title: 'test-scope', - type: 'scope', - category: 'scope', - description: 'test scope', filters: [], }, }; @@ -402,9 +396,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'test-scope' }, spec: { title: 'test-scope', - type: 'scope', - category: 'scope', - description: 'test scope', filters: [], }, parentNode: { @@ -433,9 +424,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'test-scope' }, spec: { title: 'test-scope', - type: 'scope', - category: 'scope', - description: 'test scope', filters: [], }, parentNode: { @@ -455,9 +443,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'test-scope' }, spec: { title: 'test-scope', - type: 'scope', - category: 'scope', - description: 'test scope', filters: [], }, parentNode: { @@ -485,9 +470,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'valid-scope' }, spec: { title: 'valid-scope', - type: 'scope', - category: 'scope', - description: 'valid scope', filters: [], }, parentNode: { @@ -506,9 +488,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'invalid-scope-1' }, spec: { title: 'invalid-scope-1', - type: 'scope', - category: 'scope', - description: 'invalid scope 1', filters: [], }, parentNode: { @@ -523,9 +502,6 @@ describe('ScopesSelectorService', () => { metadata: { name: 'invalid-scope-2' }, spec: { title: 'invalid-scope-2', - type: 'scope', - category: 'scope', - description: 'invalid scope 2', filters: [], }, parentNode: { diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts index c6a2a883c8f..f4eb0a4c876 100644 --- a/public/app/features/scopes/selector/types.ts +++ b/public/app/features/scopes/selector/types.ts @@ -33,10 +33,8 @@ export const ScopeSpecFilterSchema = z.object({ export const ScopeSpecSchema = z.object({ title: z.string(), - type: z.string().optional(), - description: z.string().optional(), - category: z.string().optional(), - filters: z.array(ScopeSpecFilterSchema), + defaultPath: z.array(z.string()).optional(), + filters: z.array(ScopeSpecFilterSchema).optional(), }); export const ScopeSchema = z.object({ diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index 2482e9c2053..a94093747c9 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -7,9 +7,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'cloud' }, spec: { title: 'Cloud', - type: 'indexHelper', - description: 'redundant label filter but makes queries faster', - category: 'indexHelpers', filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }], }, }, @@ -17,9 +14,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'dev' }, spec: { title: 'Dev', - type: 'cloud', - description: 'Dev', - category: 'cloud', filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }], }, }, @@ -27,9 +21,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'ops' }, spec: { title: 'Ops', - type: 'cloud', - description: 'Ops', - category: 'cloud', filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }], }, }, @@ -37,9 +28,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'prod' }, spec: { title: 'Prod', - type: 'cloud', - description: 'Prod', - category: 'cloud', filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }], }, }, @@ -47,9 +35,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'grafana' }, spec: { title: 'Grafana', - type: 'app', - description: 'Grafana', - category: 'apps', filters: [{ key: 'app', value: 'grafana', operator: 'equals' }], }, }, @@ -57,9 +42,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'mimir' }, spec: { title: 'Mimir', - type: 'app', - description: 'Mimir', - category: 'apps', filters: [{ key: 'app', value: 'mimir', operator: 'equals' }], }, }, @@ -67,9 +49,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'loki' }, spec: { title: 'Loki', - type: 'app', - description: 'Loki', - category: 'apps', filters: [{ key: 'app', value: 'loki', operator: 'equals' }], }, }, @@ -77,9 +56,6 @@ export const mocksScopes: Scope[] = [ metadata: { name: 'tempo' }, spec: { title: 'Tempo', - type: 'app', - description: 'Tempo', - category: 'apps', filters: [{ key: 'app', value: 'tempo', operator: 'equals' }], }, }, diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts index d86a6f69b02..b6badaa614a 100644 --- a/public/app/features/scopes/utils.ts +++ b/public/app/features/scopes/utils.ts @@ -6,9 +6,6 @@ export function getEmptyScopeObject(name: string, title?: string): Scope { spec: { filters: [], title: title || name, - type: '', - category: '', - description: '', }, }; } From f2eab5c3ea1e01472465f6f4d8d282becde656c2 Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 26 Sep 2025 12:12:26 +0100 Subject: [PATCH 15/31] AccessControl: Increase limit of LBAC for Datasources rules (#111560) increase json_data in mysql from TEXT to MEDIUMTEXT (16MB) --- pkg/services/sqlstore/migrations/datasource_mig.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/services/sqlstore/migrations/datasource_mig.go b/pkg/services/sqlstore/migrations/datasource_mig.go index 4d41eb96366..3e6f5907c86 100644 --- a/pkg/services/sqlstore/migrations/datasource_mig.go +++ b/pkg/services/sqlstore/migrations/datasource_mig.go @@ -146,4 +146,8 @@ func addDataSourceMigration(mg *Migrator) { mg.AddMigration("Update secure_json_data column to MediumText", NewRawSQLMigration(""). Mysql("ALTER TABLE data_source MODIFY COLUMN secure_json_data MEDIUMTEXT;"), ) + + mg.AddMigration("Update json_data column to MediumText", NewRawSQLMigration(""). + Mysql("ALTER TABLE data_source MODIFY COLUMN json_data MEDIUMTEXT;"), + ) } From 41de5fa38073fa2c25d02c280de9f15b976a7969 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 26 Sep 2025 13:18:15 +0200 Subject: [PATCH 16/31] Alerting: Fixes some pluralizations (#111661) Fixes some pluralizations --- public/locales/en-US/grafana.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 21aef1cd42d..93340dcbcc7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -939,9 +939,9 @@ "parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters." }, "used-by_one": "Used by {{ count }} notification policy", - "used-by_other": "Used by {{ count }} notification policy", + "used-by_other": "Used by {{ count }} notification policies", "used-by-rules_one": "Used by {{ count }} alert rule", - "used-by-rules_other": "Used by {{ count }} alert rule" + "used-by-rules_other": "Used by {{ count }} alert rules" }, "contact-points-filter": { "aria-label-clear": "clear", @@ -2094,7 +2094,7 @@ "inherited": "Inherited", "mute-time": "Muted when", "n-instances_one": "instance", - "n-instances_other": "instance", + "n-instances_other": "instances", "timingOptions": { "groupInterval": { "description": "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent.", @@ -2110,7 +2110,7 @@ } } }, - "n-more-policies_one": "{{count}} additional policies", + "n-more-policies_one": "{{count}} additional policy", "n-more-policies_other": "{{count}} additional policies", "new-child": "New child policy", "new-policy": "Add new policy", @@ -11968,11 +11968,11 @@ "no-search-result": "No results found for your query" }, "restore-modal": { - "folder-picker-text_one": "Please choose a folder where your dashboards will be restored.", + "folder-picker-text_one": "Please choose a folder where your dashboard will be restored.", "folder-picker-text_other": "Please choose a folder where your dashboards will be restored.", "restore-button": "Restore", "restore-loading": "Restoring...", - "text_one": "This action will restore {{numberOfDashboards}} dashboards.", + "text_one": "This action will restore {{numberOfDashboards}} dashboard.", "text_other": "This action will restore {{numberOfDashboards}} dashboards.", "title": "Restore Dashboards" } From 2b86de8b7f91da9ac789b9de353563a4b6d3d5ba Mon Sep 17 00:00:00 2001 From: Ieva Date: Fri, 26 Sep 2025 12:30:28 +0100 Subject: [PATCH 17/31] Resource Permissions: Move validator higher up (#111557) * move resource permission create and update validator higher up the chain * undo unwanted change --- pkg/registry/apis/iam/register.go | 6 + .../apis/iam/resourcepermission/models.go | 20 ++- .../apis/iam/resourcepermission/sql.go | 51 ++---- .../apis/iam/resourcepermission/sql_test.go | 113 ++---------- .../iam/resourcepermission/storage_backend.go | 7 +- .../storage_backend_test.go | 130 -------------- .../apis/iam/resourcepermission/validate.go | 42 +++++ .../iam/resourcepermission/validate_test.go | 169 ++++++++++++++++++ 8 files changed, 260 insertions(+), 278 deletions(-) create mode 100644 pkg/registry/apis/iam/resourcepermission/validate.go create mode 100644 pkg/registry/apis/iam/resourcepermission/validate_test.go diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 2910cfce37d..23327981cd7 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -328,9 +328,15 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm return serviceaccount.ValidateOnCreate(ctx, typedObj) case *iamv0.Team: return team.ValidateOnCreate(ctx, typedObj) + case *iamv0.ResourcePermission: + return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj) } return nil case admission.Update: + switch typedObj := a.GetObject().(type) { + case *iamv0.ResourcePermission: + return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj) + } return nil case admission.Delete: return nil diff --git a/pkg/registry/apis/iam/resourcepermission/models.go b/pkg/registry/apis/iam/resourcepermission/models.go index 60d18dcd011..b922543e83d 100644 --- a/pkg/registry/apis/iam/resourcepermission/models.go +++ b/pkg/registry/apis/iam/resourcepermission/models.go @@ -249,22 +249,28 @@ func (s *ResourcePermSqlBackend) parseScope(scope string) (*groupResourceName, e } // splitResourceName splits a resource name in the format -- (e.g. dashboard.grafana.app-dashboards-ad5rwqs) into its components -func (s *ResourcePermSqlBackend) splitResourceName(resourceName string) (Mapper, *groupResourceName, error) { +func splitResourceName(resourceName string) (*groupResourceName, error) { // e.g. dashboard.grafana.app-dashboards-ad5rwqs parts := strings.SplitN(resourceName, "-", 3) if len(parts) != 3 { - return nil, nil, fmt.Errorf("%w: %s", errInvalidName, resourceName) + return nil, fmt.Errorf("%w: %s", errInvalidName, resourceName) } group, resourceType, uid := parts[0], parts[1], parts[2] - mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resourceType}] - if !ok { - return nil, nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resourceType) - } - return mapper, &groupResourceName{ + return &groupResourceName{ Group: group, Resource: resourceType, Name: uid, }, nil } + +// getResourceMapper returns the Mapper of the given group and resource to access levels and scope prefix for that resource. +func (s *ResourcePermSqlBackend) getResourceMapper(group, resource string) (Mapper, error) { + mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resource}] + if !ok { + return nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resource) + } + + return mapper, nil +} diff --git a/pkg/registry/apis/iam/resourcepermission/sql.go b/pkg/registry/apis/iam/resourcepermission/sql.go index d635ef2db09..53b98046e77 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql.go +++ b/pkg/registry/apis/iam/resourcepermission/sql.go @@ -158,7 +158,12 @@ func (s *ResourcePermSqlBackend) getRbacAssignmentsWithTx(ctx context.Context, s // getResourcePermission retrieves a single ResourcePermission by its name in the format -- (e.g. dashboard.grafana.app-dashboards-ad5rwqs) func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, tx *session.SessionTx, ns types.NamespaceInfo, name string) (*v0alpha1.ResourcePermission, error) { - mapper, grn, err := s.splitResourceName(name) + grn, err := splitResourceName(name) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + + mapper, err := s.getResourceMapper(grn.Group, grn.Resource) if err != nil { return nil, apierrors.NewInternalError(err) } @@ -371,10 +376,6 @@ func (s *ResourcePermSqlBackend) existsResourcePermission(ctx context.Context, t func (s *ResourcePermSqlBackend) createResourcePermission( ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, mapper Mapper, grn *groupResourceName, v0ResourcePerm *v0alpha1.ResourcePermission, ) (int64, error) { - if err := validateCreateAndUpdateInput(v0ResourcePerm, grn); err != nil { - return 0, err - } - assignments, err := s.buildRbacAssignments(ctx, ns, mapper, v0ResourcePerm.Spec.Permissions, mapper.Scope(grn.Name)) if err != nil { return 0, err @@ -404,10 +405,6 @@ func (s *ResourcePermSqlBackend) createResourcePermission( } func (s *ResourcePermSqlBackend) updateResourcePermission(ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, mapper Mapper, grn *groupResourceName, v0ResourcePerm *v0alpha1.ResourcePermission) (int64, error) { - if err := validateCreateAndUpdateInput(v0ResourcePerm, grn); err != nil { - return 0, err - } - err := dbHelper.DB.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { currentPerms, err := s.getResourcePermission(ctx, dbHelper, tx, ns, grn.string()) if err != nil { @@ -500,38 +497,14 @@ func diffPermissions(currentPermissions, desiredPermissions []v0alpha1.ResourceP return permissionsToAdd, permissionsToRemove } -func validateCreateAndUpdateInput(v0ResourcePerm *v0alpha1.ResourcePermission, grn *groupResourceName) error { - if v0ResourcePerm == nil { - return fmt.Errorf("resource permission cannot be nil") - } - - if len(v0ResourcePerm.Spec.Permissions) == 0 { - return fmt.Errorf("resource permission must have at least one permission: %w", errInvalidSpec) - } - - // Validate that the group/resource/name in the name matches the spec - if grn.Group != v0ResourcePerm.Spec.Resource.ApiGroup || - grn.Resource != v0ResourcePerm.Spec.Resource.Resource || - grn.Name != v0ResourcePerm.Spec.Resource.Name { - return fmt.Errorf("resource permission name does not match spec: %w", errInvalidSpec) - } - - // Check for duplicate entities (same kind and name should appear only once) - seen := make(map[string]bool) - for _, perm := range v0ResourcePerm.Spec.Permissions { - key := fmt.Sprintf("%s:%s", perm.Kind, perm.Name) - if seen[key] { - return fmt.Errorf("duplicate entity found: kind=%s, name=%s (each entity can only appear once per resource): %w", perm.Kind, perm.Name, errInvalidSpec) - } - seen[key] = true - } - - return nil -} - // deleteResourcePermission deletes resource permissions for a single ResourcePermission resource referenced by its name in the format -- (e.g. dashboard.grafana.app-dashboards-ad5rwqs) func (s *ResourcePermSqlBackend) deleteResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, name string) error { - mapper, grn, err := s.splitResourceName(name) + grn, err := splitResourceName(name) + if err != nil { + return err + } + + mapper, err := s.getResourceMapper(grn.Group, grn.Resource) if err != nil { return err } diff --git a/pkg/registry/apis/iam/resourcepermission/sql_test.go b/pkg/registry/apis/iam/resourcepermission/sql_test.go index 8ff91859d9e..df6c170d238 100644 --- a/pkg/registry/apis/iam/resourcepermission/sql_test.go +++ b/pkg/registry/apis/iam/resourcepermission/sql_test.go @@ -450,7 +450,10 @@ func TestIntegration_ResourcePermSqlBackend_CreateResourcePermission(t *testing. sqlHelper, _ := backend.dbProvider(ctx) backend.identityStore = NewFakeIdentityStore(t) - mapper, grn, err := backend.splitResourceName(resourcePerm.Name) + grn, err := splitResourceName(resourcePerm.Name) + require.NoError(t, err) + + mapper, err := backend.getResourceMapper(grn.Group, grn.Resource) require.NoError(t, err) rv, err := backend.createResourcePermission(ctx, sqlHelper, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm) @@ -544,7 +547,10 @@ func TestIntegration_ResourcePermSqlBackend_UpdateResourcePermission(t *testing. }, } - mapper, grn, err := backend.splitResourceName(resourcePerm.Name) + grn, err := splitResourceName(resourcePerm.Name) + require.NoError(t, err) + + mapper, err := backend.getResourceMapper(grn.Group, grn.Resource) require.NoError(t, err) _, err = backend.updateResourcePermission(ctx, sql, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm) @@ -583,7 +589,10 @@ func TestIntegration_ResourcePermSqlBackend_UpdateResourcePermission(t *testing. }, } - mapper, grn, err := backend.splitResourceName(resourcePerm.Name) + grn, err := splitResourceName(resourcePerm.Name) + require.NoError(t, err) + + mapper, err := backend.getResourceMapper(grn.Group, grn.Resource) require.NoError(t, err) rv, err := backend.updateResourcePermission(ctx, sql, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm) @@ -670,104 +679,6 @@ func (f *fakeIdentityStore) GetUserInternalID(ctx context.Context, ns types.Name return &legacy.GetUserInternalIDResult{ID: id}, nil } -func TestValidateCreateAndUpdateInput(t *testing.T) { - grn := &groupResourceName{ - Group: "dashboard.grafana.app", - Resource: "dashboards", - Name: "test-dashboard", - } - - t.Run("Should pass validation with valid permissions", func(t *testing.T) { - resourcePerm := &v0alpha1.ResourcePermission{ - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "dashboard.grafana.app", - Resource: "dashboards", - Name: "test-dashboard", - }, - Permissions: []v0alpha1.ResourcePermissionspecPermission{ - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Editor", - Verb: "edit", - }, - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Viewer", // Different entity name - should be allowed - Verb: "view", - }, - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser, - Name: "user-1", - Verb: "edit", // Different kind - should be allowed - }, - }, - }, - } - - err := validateCreateAndUpdateInput(resourcePerm, grn) - require.NoError(t, err) - }) - - t.Run("Should fail validation with duplicate entities", func(t *testing.T) { - resourcePerm := &v0alpha1.ResourcePermission{ - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "dashboard.grafana.app", - Resource: "dashboards", - Name: "test-dashboard", - }, - Permissions: []v0alpha1.ResourcePermissionspecPermission{ - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Editor", - Verb: "edit", - }, - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Editor", - Verb: "view", // Same entity, different verb - should fail - }, - }, - }, - } - - err := validateCreateAndUpdateInput(resourcePerm, grn) - require.Error(t, err) - require.Contains(t, err.Error(), "duplicate entity found") - require.Contains(t, err.Error(), "kind=BasicRole") - require.Contains(t, err.Error(), "name=Editor") - require.Contains(t, err.Error(), "each entity can only appear once per resource") - }) - - t.Run("Should pass validation with same name but different kinds", func(t *testing.T) { - resourcePerm := &v0alpha1.ResourcePermission{ - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "dashboard.grafana.app", - Resource: "dashboards", - Name: "test-dashboard", - }, - Permissions: []v0alpha1.ResourcePermissionspecPermission{ - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser, - Name: "editor", // Same name but different kind - Verb: "edit", - }, - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "editor", // Same name but different kind - Verb: "view", - }, - }, - }, - } - - err := validateCreateAndUpdateInput(resourcePerm, grn) - require.NoError(t, err) - }) -} - func TestIntegration_UpdateResourcePermission_VerbChange(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go index 97d252a6fd5..3e855b50250 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go @@ -234,11 +234,16 @@ func (s *ResourcePermSqlBackend) WriteEvent(ctx context.Context, event resource. return 0, errDatabaseHelper } - mapper, grn, err := s.splitResourceName(event.Key.Name) + grn, err := splitResourceName(event.Key.Name) if err != nil { return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid resource name %q: %v", event.Key.Name, err.Error())) } + mapper, err := s.getResourceMapper(grn.Group, grn.Resource) + if err != nil { + return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid group/resource in resource name %q: %v", event.Key.Name, err.Error())) + } + if grn.Name == "" { return 0, fmt.Errorf("resource name cannot be empty: %w", errInvalidName) } diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go index aac5eb5d38d..8a6806d4b9c 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go @@ -143,71 +143,6 @@ func TestWriteEvent_Add(t *testing.T) { require.Contains(t, err.Error(), "requires a valid namespace") }) - t.Run("should error if there is no permission", func(t *testing.T) { - backend := ProvideStorageBackend(dbProvider) - - resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ - ObjectMeta: metav1.ObjectMeta{ - Name: "folder.grafana.app-folders-fold1", - Namespace: "default", - }, - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "folder.grafana.app", - Resource: "folders", - Name: "fold1", - }, - }, - }) - require.NoError(t, err) - - gr := v0alpha1.ResourcePermissionInfo.GroupResource() - rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, - Object: resourcePerm, - }) - require.Zero(t, rv) - require.NotNil(t, err) - require.Contains(t, err.Error(), errInvalidSpec.Error()) - }) - - t.Run("should error if name and spec do not match", func(t *testing.T) { - backend := ProvideStorageBackend(dbProvider) - - resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ - ObjectMeta: metav1.ObjectMeta{ - Name: "folder.grafana.app-folders-fold1", - Namespace: "default", - }, - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "folder.grafana.app", - Resource: "folders", - Name: "fold2", - }, - Permissions: []v0alpha1.ResourcePermissionspecPermission{ - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Viewer", - Verb: "Admin", - }, - }, - }, - }) - require.NoError(t, err) - - gr := v0alpha1.ResourcePermissionInfo.GroupResource() - rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, - Object: resourcePerm, - }) - require.Zero(t, rv) - require.NotNil(t, err) - require.Contains(t, err.Error(), errInvalidSpec.Error()) - }) - t.Run("should error if resource name is empty", func(t *testing.T) { backend := ProvideStorageBackend(dbProvider) @@ -747,71 +682,6 @@ func TestWriteEvent_Modify(t *testing.T) { require.Contains(t, err.Error(), "requires a valid namespace") }) - t.Run("should error if there are no permission specified in the body", func(t *testing.T) { - backend := ProvideStorageBackend(dbProvider) - - resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ - ObjectMeta: metav1.ObjectMeta{ - Name: "folder.grafana.app-folders-fold1", - Namespace: "default", - }, - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "folder.grafana.app", - Resource: "folders", - Name: "fold1", - }, - }, - }) - require.NoError(t, err) - - gr := v0alpha1.ResourcePermissionInfo.GroupResource() - rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ - Type: resourcepb.WatchEvent_MODIFIED, - Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, - Object: resourcePerm, - }) - require.Zero(t, rv) - require.NotNil(t, err) - require.Contains(t, err.Error(), errInvalidSpec.Error()) - }) - - t.Run("should error if name and spec do not match", func(t *testing.T) { - backend := ProvideStorageBackend(dbProvider) - - resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{ - ObjectMeta: metav1.ObjectMeta{ - Name: "folder.grafana.app-folders-fold1", - Namespace: "default", - }, - Spec: v0alpha1.ResourcePermissionSpec{ - Resource: v0alpha1.ResourcePermissionspecResource{ - ApiGroup: "folder.grafana.app", - Resource: "folders", - Name: "fold2", - }, - Permissions: []v0alpha1.ResourcePermissionspecPermission{ - { - Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole, - Name: "Viewer", - Verb: "Admin", - }, - }, - }, - }) - require.NoError(t, err) - - gr := v0alpha1.ResourcePermissionInfo.GroupResource() - rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{ - Type: resourcepb.WatchEvent_MODIFIED, - Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"}, - Object: resourcePerm, - }) - require.Zero(t, rv) - require.NotNil(t, err) - require.Contains(t, err.Error(), errInvalidSpec.Error()) - }) - t.Run("should error if resource name is empty", func(t *testing.T) { backend := ProvideStorageBackend(dbProvider) diff --git a/pkg/registry/apis/iam/resourcepermission/validate.go b/pkg/registry/apis/iam/resourcepermission/validate.go new file mode 100644 index 00000000000..a4832b66dc9 --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/validate.go @@ -0,0 +1,42 @@ +package resourcepermission + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" +) + +func ValidateCreateAndUpdateInput(ctx context.Context, v0ResourcePerm *v0alpha1.ResourcePermission) error { + if v0ResourcePerm == nil { + return fmt.Errorf("resource permission cannot be nil") + } + + if len(v0ResourcePerm.Spec.Permissions) == 0 { + return fmt.Errorf("resource permission must have at least one permission: %w", errInvalidSpec) + } + + grn, err := splitResourceName(v0ResourcePerm.Name) + if err != nil { + return fmt.Errorf("invalid resource permission name: %w", err) + } + + // Validate that the group/resource/name in the name matches the spec + if grn.Group != v0ResourcePerm.Spec.Resource.ApiGroup || + grn.Resource != v0ResourcePerm.Spec.Resource.Resource || + grn.Name != v0ResourcePerm.Spec.Resource.Name { + return fmt.Errorf("resource permission name does not match spec: %w", errInvalidSpec) + } + + // Check for duplicate entities (same kind and name should appear only once) + seen := make(map[string]bool) + for _, perm := range v0ResourcePerm.Spec.Permissions { + key := fmt.Sprintf("%s:%s", perm.Kind, perm.Name) + if seen[key] { + return fmt.Errorf("duplicate entity found: kind=%s, name=%s (each entity can only appear once per resource): %w", perm.Kind, perm.Name, errInvalidSpec) + } + seen[key] = true + } + + return nil +} diff --git a/pkg/registry/apis/iam/resourcepermission/validate_test.go b/pkg/registry/apis/iam/resourcepermission/validate_test.go new file mode 100644 index 00000000000..f46c13bee1e --- /dev/null +++ b/pkg/registry/apis/iam/resourcepermission/validate_test.go @@ -0,0 +1,169 @@ +package resourcepermission + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" +) + +func TestValidateOnCreate(t *testing.T) { + tests := []struct { + name string + obj *iamv0alpha1.ResourcePermission + want error + }{ + { + name: "missing permissions - should fail", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "folder.grafana.app-folders-test_folder", + }, + Spec: iamv0alpha1.ResourcePermissionSpec{ + Resource: iamv0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "test_folder", + }, + Permissions: []iamv0alpha1.ResourcePermissionspecPermission{}, + }, + }, + want: errInvalidSpec, + }, + { + name: "invalid name - should fail", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "some-invalid-name", + }, + }, + want: errInvalidName, + }, + { + name: "mismatched name and spec - should fail", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "folder.grafana.app-folders-test_folder", + }, + Spec: iamv0alpha1.ResourcePermissionSpec{ + Resource: iamv0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "some_other_folder", + }, + Permissions: []iamv0alpha1.ResourcePermissionspecPermission{ + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser, + Name: "test-user", + Verb: "view", + }, + }, + }, + }, + want: errInvalidSpec, + }, + { + name: "valid spec - should pass", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "folder.grafana.app-folders-test_folder", + }, + Spec: iamv0alpha1.ResourcePermissionSpec{ + Resource: iamv0alpha1.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", + Resource: "folders", + Name: "test_folder", + }, + Permissions: []iamv0alpha1.ResourcePermissionspecPermission{ + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Editor", + Verb: "edit", + }, + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Viewer", // Different entity name - should be allowed + Verb: "view", + }, + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser, + Name: "user-1", + Verb: "edit", // Different kind - should be allowed + }, + }, + }, + }, + want: nil, + }, + { + name: "duplicate entities - should fail", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "dashboard.grafana.app-dashboards-test_dashboard", + }, + Spec: iamv0alpha1.ResourcePermissionSpec{ + Resource: iamv0alpha1.ResourcePermissionspecResource{ + ApiGroup: "dashboard.grafana.app", + Resource: "dashboards", + Name: "test_dashboard", + }, + Permissions: []iamv0alpha1.ResourcePermissionspecPermission{ + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Editor", + Verb: "edit", + }, + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Editor", + Verb: "view", // Same entity, different verb - should fail + }, + }, + }, + }, + want: errInvalidSpec, + }, + { + name: "duplicate names but different kinds - should pass", + obj: &iamv0alpha1.ResourcePermission{ + ObjectMeta: v1.ObjectMeta{ + Name: "dashboard.grafana.app-dashboards-test_dashboard", + }, + Spec: iamv0alpha1.ResourcePermissionSpec{ + Resource: iamv0alpha1.ResourcePermissionspecResource{ + ApiGroup: "dashboard.grafana.app", + Resource: "dashboards", + Name: "test_dashboard", + }, + Permissions: []iamv0alpha1.ResourcePermissionspecPermission{ + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser, + Name: "Editor", + Verb: "edit", + }, + { + Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole, + Name: "Editor", + Verb: "view", + }, + }, + }, + }, + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateCreateAndUpdateInput(context.Background(), test.obj) + if test.want == nil { + assert.NoError(t, err) + } else { + assert.ErrorAs(t, test.want, &err) + } + }) + } +} From 6374caec1a3b12bca08b2d841d02a880872f27e9 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 26 Sep 2025 13:55:33 +0200 Subject: [PATCH 18/31] FolderPicker: Show parent folder when searching (#111026) * Add FolderParent component * Move parent folder after the folder name * Small improvements --- .../NestedFolderPicker/FolderParent.tsx | 53 +++++++++++++++++++ .../NestedFolderPicker/NestedFolderList.tsx | 3 +- .../NestedFolderPicker/NestedFolderPicker.tsx | 1 + 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 public/app/core/components/NestedFolderPicker/FolderParent.tsx diff --git a/public/app/core/components/NestedFolderPicker/FolderParent.tsx b/public/app/core/components/NestedFolderPicker/FolderParent.tsx new file mode 100644 index 00000000000..6e2b3842646 --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/FolderParent.tsx @@ -0,0 +1,53 @@ +import { memo } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { Box, Text } from '@grafana/ui'; + +import { useGetFolderQueryFacade } from '../../../api/clients/folder/v1beta1/hooks'; +import { DashboardsTreeItem } from '../../../features/browse-dashboards/types'; + +interface ParentTextProps { + folder: string; +} + +function ParentText({ folder }: ParentTextProps) { + return ( + + + /{folder} + + + ); +} + +export const FolderParent = memo(function FolderParent({ item }: { item: DashboardsTreeItem }) { + if (item.item.kind !== 'folder') { + return null; + } + + if (item.item.parentTitle) { + return ; + } + + const parentUID = item.item.parentUID || item.parentUID; + + if (parentUID) { + return ; + } + + return null; +}); + +function NetworkFolderParent({ uid }: { uid: string }) { + const result = useGetFolderQueryFacade(uid); + + if (result.isLoading) { + return ; + } + + if (result.data) { + return ; + } + + return null; +} diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx index 46ef137c53f..aa099cff928 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -14,6 +14,7 @@ import { DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { DashboardViewItem } from 'app/features/search/types'; import { useSelector } from 'app/types/store'; +import { FolderParent } from './FolderParent'; import { FolderRepo } from './FolderRepo'; const ROW_HEIGHT = 40; @@ -216,6 +217,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) { {item.title} + ); @@ -282,7 +284,6 @@ const getStyles = (theme: GrafanaTheme2) => { alignItems: 'center', gap: theme.spacing(1), lineHeight: ROW_HEIGHT + 'px', - flexGrow: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 3fca40f809e..826f8ec5002 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -234,6 +234,7 @@ export function NestedFolderPicker({ title: item.title, uid: item.uid, parentUID: item.parentUID, + parentTitle: item.parentTitle, }, })) ?? []; } From 0163d1888ecb4efc444a1bdd7f4167f2516644ba Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Fri, 26 Sep 2025 14:09:28 +0200 Subject: [PATCH 19/31] Docs: Marking the image renderer plugin as deprecated (#111669) --- docs/sources/setup-grafana/image-rendering/_index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index 568f2d4e6a9..32df8dc2154 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -32,6 +32,12 @@ Alert notifications can include images, but rendering many images at the same ti ## Install Grafana Image Renderer plugin +{{< admonition type="caution" >}} +Starting with Grafana v12.2, the Grafana Image Renderer plugin is deprecated and is no longer maintained. + +Instead, use the Grafana Image Renderer remote rendering service. +{{< /admonition >}} + {{< admonition type="note" >}} All PhantomJS support has been removed. Instead, use the Grafana Image Renderer plugin or remote rendering service. {{< /admonition >}} From a8bff4525642a4e25be8dca6f83b3eb00c6cec0d Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Fri, 26 Sep 2025 07:18:20 -0500 Subject: [PATCH 20/31] Loki: resource frontend browser cache (#110488) feat: allow frontend to set browser cache for config endpoint --- pkg/tsdb/loki/loki.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 4b68c66b42a..5626ca94ee5 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -162,6 +162,13 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender respHeaders := map[string][]string{ "content-type": {"application/json"}, } + + // frontend sets the X-Grafana-Cache with the desired response cache control value + if len(req.GetHTTPHeaders().Get("X-Grafana-Cache")) > 0 { + respHeaders["X-Grafana-Cache"] = []string{"y"} + respHeaders["Cache-Control"] = []string{req.GetHTTPHeaders().Get("X-Grafana-Cache")} + } + if rawLokiResponse.Encoding != "" { respHeaders["content-encoding"] = []string{rawLokiResponse.Encoding} } From b8f23eacd4acdfd51655c2806cc250456e1f11af Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 26 Sep 2025 09:31:50 -0400 Subject: [PATCH 21/31] Alerting: Migrate to integration schema (#111643) * update tests to assert against snapshot * remove channel_config package replaced by schemas from alerting module * update references to use new schema --- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- pkg/api/alerting.go | 39 +- .../ngalert/api/api_provisioning_test.go | 3 +- pkg/services/ngalert/models/receivers.go | 38 +- pkg/services/ngalert/models/receivers_test.go | 73 +- pkg/services/ngalert/models/testing.go | 23 +- .../channels_config/available_channels.go | 2154 -------- .../available_channels_mimir.go | 1572 ------ .../available_channels_test.go | 290 -- .../notifier/channels_config/plugin.go | 122 - pkg/services/ngalert/notifier/crypto.go | 12 +- pkg/services/ngalert/notifier/email_test.go | 2 +- .../notifier/legacy_storage/receivers_test.go | 18 +- .../ngalert/provisioning/contactpoints.go | 18 +- .../provisioning/contactpoints_test.go | 9 +- .../alerting/api_available_channel_test.go | 14 +- .../alerting/api_notification_channel_test.go | 10 +- .../alert-notifiers-v1-snapshot.json | 4310 +++++++++++++++++ .../alert-notifiers-v2-snapshot.json | 3 +- .../notifications/receivers/receiver_test.go | 8 +- 23 files changed, 4475 insertions(+), 4255 deletions(-) delete mode 100644 pkg/services/ngalert/notifier/channels_config/available_channels.go delete mode 100644 pkg/services/ngalert/notifier/channels_config/available_channels_mimir.go delete mode 100644 pkg/services/ngalert/notifier/channels_config/available_channels_test.go delete mode 100644 pkg/services/ngalert/notifier/channels_config/plugin.go create mode 100644 pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 64c5a52c1a8..a7ac326bd6c 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -204,7 +204,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 // indirect + github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d // indirect github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index b93048ed57a..b561836b7fd 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -721,8 +721,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0= -github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= +github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d h1:zzEty7HgfXbQ/RiBCJFMqaZiJlqiXuz/Zbc6/H6ksuM= +github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A= github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E= diff --git a/go.mod b/go.mod index 5a986999c43..5ea667a79cf 100644 --- a/go.mod +++ b/go.mod @@ -86,7 +86,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.2 // @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-20250925193206-bd061d3d9185 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index b444408d771..3a861217971 100644 --- a/go.sum +++ b/go.sum @@ -1585,8 +1585,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-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0= -github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= +github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d h1:zzEty7HgfXbQ/RiBCJFMqaZiJlqiXuz/Zbc6/H6ksuM= +github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc= github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A= github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E= diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 2656ea3e1f0..27abb6ebbfc 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -5,19 +5,48 @@ import ( "slices" "strings" + "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" ) func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response { return func(r *contextmodel.ReqContext) response.Response { + v2 := notify.GetSchemaForAllIntegrations() + slices.SortFunc(v2, func(a, b schema.IntegrationTypeSchema) int { + return strings.Compare(string(a.Type), string(b.Type)) + }) if r.Query("version") == "2" { - v2 := slices.SortedFunc(channels_config.GetAvailableNotifiersV2(), func(a, b *channels_config.VersionedNotifierPlugin) int { - return strings.Compare(a.Type, b.Type) - }) return response.JSON(http.StatusOK, v2) } - return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers()) + + type NotifierPlugin struct { + Type string `json:"type"` + TypeAlias string `json:"typeAlias,omitempty"` + Name string `json:"name"` + Heading string `json:"heading"` + Description string `json:"description"` + Info string `json:"info"` + Options []schema.Field `json:"options"` + } + + result := make([]*NotifierPlugin, 0, len(v2)) + for _, s := range v2 { + v1, ok := s.GetVersion(schema.V1) + if !ok { + continue + } + result = append(result, &NotifierPlugin{ + Type: string(s.Type), + Name: s.Name, + Description: s.Description, + Heading: s.Heading, + Info: s.Info, + Options: v1.Options, + }) + } + return response.JSON(http.StatusOK, result) } } diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 81ad6fac9d9..a7dd8f1f2fe 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -15,6 +15,7 @@ import ( "time" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" prometheus "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/timeinterval" @@ -2037,7 +2038,7 @@ func TestApiContactPointExportSnapshot(t *testing.T) { integration := models.IntegrationGen( models.IntegrationMuts.WithName(allIntegrationsName), models.IntegrationMuts.WithUID(fmt.Sprintf("%s-uid", integrationType)), - models.IntegrationMuts.WithValidConfig(integrationType), + models.IntegrationMuts.WithValidConfig(schema.IntegrationType(integrationType)), )() integration.DisableResolveMessage = redacted allIntegrations = append(allIntegrations, integration) diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 8d942975b5e..1f892147938 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -13,8 +13,7 @@ import ( "strings" alertingNotify "github.com/grafana/alerting/notify" - - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" + "github.com/grafana/alerting/receivers/schema" ) // GetReceiverQuery represents a query for a single receiver. @@ -228,30 +227,35 @@ func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath { // IntegrationConfig - The integration configuration // error - Error if integration type not found or invalid version specified func IntegrationConfigFromType(integrationType string, version *string) (IntegrationConfig, error) { - versionConfig, err := channels_config.ConfigForIntegrationType(integrationType) - if err != nil { - return IntegrationConfig{}, err + typeSchema, ok := alertingNotify.GetSchemaForIntegration(schema.IntegrationType(integrationType)) + if !ok { + return IntegrationConfig{}, fmt.Errorf("integration type %s not found", integrationType) } - // if particular version is requested and the version returned does not match, try to get the correct version - if version != nil && *version != string(versionConfig.Version) { - exists := false - versionConfig, exists = versionConfig.Plugin.GetVersion(channels_config.NotifierVersion(*version)) - if !exists { - return IntegrationConfig{}, fmt.Errorf("version %s not found in config", *version) - } + if version == nil { + return IntegrationConfigFromSchema(typeSchema, typeSchema.CurrentVersion) + } + return IntegrationConfigFromSchema(typeSchema, schema.Version(*version)) +} + +// IntegrationConfigFromSchema returns an integration configuration for a given version of the integration type schema. +// Returns an error if the schema does not have such version +func IntegrationConfigFromSchema(typeSchema schema.IntegrationTypeSchema, version schema.Version) (IntegrationConfig, error) { + typeVersion, ok := typeSchema.GetVersion(version) + if !ok { + return IntegrationConfig{}, fmt.Errorf("version %s not found in config", version) } integrationConfig := IntegrationConfig{ - Type: versionConfig.Plugin.Type, - Version: string(versionConfig.Version), - Fields: make(map[string]IntegrationField, len(versionConfig.Options)), + Type: string(typeSchema.Type), + Version: string(typeVersion.Version), + Fields: make(map[string]IntegrationField, len(typeVersion.Options)), } - for _, option := range versionConfig.Options { + for _, option := range typeVersion.Options { integrationConfig.Fields[option.PropertyName] = notifierOptionToIntegrationField(option) } return integrationConfig, nil } -func notifierOptionToIntegrationField(option channels_config.NotifierOption) IntegrationField { +func notifierOptionToIntegrationField(option schema.Field) IntegrationField { f := IntegrationField{ Name: option.PropertyName, Secure: option.Secure, diff --git a/pkg/services/ngalert/models/receivers_test.go b/pkg/services/ngalert/models/receivers_test.go index 3b691953edd..0c6c08c3d93 100644 --- a/pkg/services/ngalert/models/receivers_test.go +++ b/pkg/services/ngalert/models/receivers_test.go @@ -6,11 +6,10 @@ import ( "testing" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -41,14 +40,14 @@ func TestReceiver_EncryptDecrypt(t *testing.T) { encryptFn := Base64Enrypt decryptnFn := Base64Decrypt // Test that all known integration types encrypt and decrypt their secrets. - for integrationType := range alertingNotify.AllKnownConfigsForTesting { - t.Run(integrationType, func(t *testing.T) { + for it := range alertingNotify.AllKnownConfigsForTesting { + integrationType := schema.IntegrationType(it) + t.Run(string(integrationType), func(t *testing.T) { decrypedIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))() - encrypted := decrypedIntegration.Clone() - secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1) - assert.NoError(t, err) - for _, key := range secrets { + typeVersion, ok := alertingNotify.GetSchemaVersionForIntegration(integrationType, schema.V1) + require.True(t, ok) + for _, key := range typeVersion.GetSecretFieldsPaths() { val, ok, err := extractField(encrypted.Settings, NewIntegrationFieldPath(key)) assert.NoError(t, err) if ok { @@ -59,7 +58,7 @@ func TestReceiver_EncryptDecrypt(t *testing.T) { } testIntegration := decrypedIntegration.Clone() - err = testIntegration.Encrypt(encryptFn) + err := testIntegration.Encrypt(encryptFn) assert.NoError(t, err) require.Equal(t, encrypted, testIntegration) @@ -77,14 +76,15 @@ func TestIntegration_Redact(t *testing.T) { return "TESTREDACTED" } // Test that all known integration types redact their secrets. - for integrationType := range alertingNotify.AllKnownConfigsForTesting { - t.Run(integrationType, func(t *testing.T) { + for it := range alertingNotify.AllKnownConfigsForTesting { + integrationType := schema.IntegrationType(it) + t.Run(string(integrationType), func(t *testing.T) { validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))() expected := validIntegration.Clone() - secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1) - assert.NoError(t, err) - for _, key := range secrets { + version, ok := alertingNotify.GetSchemaVersionForIntegration(integrationType, schema.V1) + require.True(t, ok) + for _, key := range version.GetSecretFieldsPaths() { err := setField(expected.Settings, NewIntegrationFieldPath(key), func(current any) any { if s, isString := current.(string); isString && s != "" { delete(expected.SecureSettings, key) @@ -106,8 +106,9 @@ func TestIntegration_Validate(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) // Test that all known integration types are valid. - for integrationType := range alertingNotify.AllKnownConfigsForTesting { - t.Run(integrationType, func(t *testing.T) { + for it := range alertingNotify.AllKnownConfigsForTesting { + integrationType := schema.IntegrationType(it) + t.Run(string(integrationType), func(t *testing.T) { validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))() assert.NoError(t, validIntegration.Encrypt(Base64Enrypt)) assert.NoErrorf(t, validIntegration.Validate(Base64Decrypt), "integration should be valid") @@ -241,19 +242,19 @@ func TestIntegration_WithExistingSecureFields(t *testing.T) { func TestSecretsIntegrationConfig(t *testing.T) { // Test that all known integration types have a config and correctly mark their secrets as secure. - for integrationType := range alertingNotify.AllKnownConfigsForTesting { - t.Run(integrationType, func(t *testing.T) { - config, err := IntegrationConfigFromType(integrationType, nil) + for it := range alertingNotify.AllKnownConfigsForTesting { + integrationType := schema.IntegrationType(it) + t.Run(string(integrationType), func(t *testing.T) { + schemaType, ok := alertingNotify.GetSchemaForIntegration(integrationType) + require.True(t, ok) + + config, err := IntegrationConfigFromSchema(schemaType, schema.V1) assert.NoError(t, err) - t.Run("v1 is current", func(t *testing.T) { - configv1, err := IntegrationConfigFromType(integrationType, util.Pointer(string(channels_config.V1))) - assert.NoError(t, err) - assert.Equal(t, config, configv1) - }) + version, ok := schemaType.GetVersion(schema.V1) + require.True(t, ok) - secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1) - assert.NoError(t, err) + secrets := version.GetSecretFieldsPaths() allSecrets := make(map[string]struct{}, len(secrets)) for _, key := range secrets { allSecrets[key] = struct{}{} @@ -270,17 +271,12 @@ func TestSecretsIntegrationConfig(t *testing.T) { }) } - t.Run("Unknown type returns error", func(t *testing.T) { - _, err := IntegrationConfigFromType("__--**unknown_type**--__", nil) - assert.Error(t, err) - }) - t.Run("Unknown version returns error", func(t *testing.T) { - version := util.Pointer("__--**unknown_version**--__") - types := maps.Keys(alertingNotify.AllKnownConfigsForTesting) - for itype := range types { - _, err := IntegrationConfigFromType(itype, version) - assert.Errorf(t, err, "unknown version for integration type %s did not return error but should", itype) + for s := range maps.Keys(alertingNotify.AllKnownConfigsForTesting) { + schemaType, _ := alertingNotify.GetSchemaForIntegration(schema.IntegrationType(s)) + _, err := IntegrationConfigFromSchema(schemaType, "unknown") + require.Error(t, err) + return } }) } @@ -289,8 +285,9 @@ func TestIntegration_SecureFields(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) // Test that all known integration types have a config and correctly mark their secrets as secure. - for integrationType := range alertingNotify.AllKnownConfigsForTesting { - t.Run(integrationType, func(t *testing.T) { + for it := range alertingNotify.AllKnownConfigsForTesting { + integrationType := schema.IntegrationType(it) + t.Run(string(integrationType), func(t *testing.T) { t.Run("contains SecureSettings", func(t *testing.T) { validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))() expected := make(map[string]bool, len(validIntegration.SecureSettings)) diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 7f124644f34..2164ca8f86f 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -13,6 +13,8 @@ import ( "github.com/go-openapi/strfmt" "github.com/google/uuid" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/alerting/receivers/webex" "github.com/grafana/grafana-plugin-sdk-go/data" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/pkg/labels" @@ -1209,7 +1211,7 @@ func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver } } -func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] { +func (n ReceiverMutators) WithValidIntegration(integrationType schema.IntegrationType) Mutator[Receiver] { return func(r *Receiver) { // TODO add support for v0 integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))() @@ -1217,7 +1219,7 @@ func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[R } } -func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] { +func (n ReceiverMutators) WithInvalidIntegration(integrationType schema.IntegrationType) Mutator[Receiver] { return func(r *Receiver) { // TODO add support for v0 integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))() @@ -1278,7 +1280,7 @@ func IntegrationGen(mutators ...Mutator[Integration]) func() Integration { SecureSettings: make(map[string]string), } - IntegrationMuts.WithValidConfig(randomIntegrationType)(&c) + IntegrationMuts.WithValidConfig(schema.IntegrationType(randomIntegrationType))(&c) for _, mutator := range mutators { mutator(&c) @@ -1312,11 +1314,12 @@ func (n IntegrationMutators) WithName(name string) Mutator[Integration] { } } -func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] { +func (n IntegrationMutators) WithValidConfig(integrationType schema.IntegrationType) Mutator[Integration] { return func(c *Integration) { // TODO add support for v0 integrations - config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name) - integrationConfig, _ := IntegrationConfigFromType(integrationType, nil) + config := alertingNotify.AllKnownConfigsForTesting[string(integrationType)].GetRawNotifierConfig(c.Name) + typeSchema, _ := alertingNotify.GetSchemaForIntegration(integrationType) + integrationConfig, _ := IntegrationConfigFromSchema(typeSchema, schema.V1) c.Config = integrationConfig var settings map[string]any @@ -1332,13 +1335,13 @@ func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Int } } -func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] { +func (n IntegrationMutators) WithInvalidConfig(integrationType schema.IntegrationType) Mutator[Integration] { return func(c *Integration) { - integrationConfig, _ := IntegrationConfigFromType(integrationType, nil) - c.Config = integrationConfig + typeSchema, _ := alertingNotify.GetSchemaForIntegration(integrationType) + c.Config, _ = IntegrationConfigFromSchema(typeSchema, schema.V1) c.Settings = map[string]interface{}{} c.SecureSettings = map[string]string{} - if integrationType == "webex" { + if integrationType == webex.Type { // Webex passes validation without any settings but should fail with an unparsable URL. c.Settings["api_url"] = "(*^$*^%!@#$*()" } diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go deleted file mode 100644 index d16efb0348a..00000000000 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ /dev/null @@ -1,2154 +0,0 @@ -package channels_config - -import ( - "fmt" - "iter" - "maps" - "strings" - - "github.com/grafana/alerting/receivers/jira" - alertingMqtt "github.com/grafana/alerting/receivers/mqtt" - alertingOpsgenie "github.com/grafana/alerting/receivers/opsgenie" - alertingPagerduty "github.com/grafana/alerting/receivers/pagerduty" - alertingTemplates "github.com/grafana/alerting/templates" -) - -type NotifierVersion string - -const ( - // versions that contain the "mimir" tag in their name are dedicated to integrations supported by Mimir. - // By default, all mimir integrations should use the V0mimir1 version. - // Exceptions are Mimir integrations that have multiple configurations for the same Grafana type. - - V0mimir1 NotifierVersion = "v0mimir1" - V0mimir2 NotifierVersion = "v0mimir2" - V1 NotifierVersion = "v1" -) - -// GetAvailableNotifiers returns the metadata of all the notification channels that can be configured. -func GetAvailableNotifiers() []*NotifierPlugin { - pushoverSoundOptions := []SelectOption{ - { - Value: "default", - Label: "Default", - }, - { - Value: "pushover", - Label: "Pushover", - }, { - Value: "bike", - Label: "Bike", - }, { - Value: "bugle", - Label: "Bugle", - }, { - Value: "cashregister", - Label: "Cashregister", - }, { - Value: "classical", - Label: "Classical", - }, { - Value: "cosmic", - Label: "Cosmic", - }, { - Value: "falling", - Label: "Falling", - }, { - Value: "gamelan", - Label: "Gamelan", - }, { - Value: "incoming", - Label: "Incoming", - }, { - Value: "intermission", - Label: "Intermission", - }, { - Value: "magic", - Label: "Magic", - }, { - Value: "mechanical", - Label: "Mechanical", - }, { - Value: "pianobar", - Label: "Pianobar", - }, { - Value: "siren", - Label: "Siren", - }, { - Value: "spacealarm", - Label: "Spacealarm", - }, { - Value: "tugboat", - Label: "Tugboat", - }, { - Value: "alien", - Label: "Alien", - }, { - Value: "climb", - Label: "Climb", - }, { - Value: "persistent", - Label: "Persistent", - }, { - Value: "echo", - Label: "Echo", - }, { - Value: "updown", - Label: "Updown", - }, { - Value: "none", - Label: "None", - }, - } - - pushoverPriorityOptions := []SelectOption{ - { - Value: "2", - Label: "Emergency", - }, - { - Value: "1", - Label: "High", - }, - { - Value: "0", - Label: "Normal", - }, - { - Value: "-1", - Label: "Low", - }, - { - Value: "-2", - Label: "Lowest", - }, - } - - 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", - Name: "DingDing", - Description: "Sends HTTP POST request to DingDing", - Heading: "DingDing settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", - PropertyName: "url", - Required: true, - Secure: true, - }, - { - Label: "Message Type", - Element: ElementTypeSelect, - PropertyName: "msgType", - SelectOptions: []SelectOption{ - { - Value: "link", - Label: "Link"}, - { - Value: "actionCard", - Label: "ActionCard", - }, - }, - }, - { // New in 9.3. - Label: "Title", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Templated title of the message", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "title", - }, - { // New in 8.0. - Label: "Message", - Element: ElementTypeTextArea, - Description: "Custom DingDing message. You can use template variables.", - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - }, - }, - { - Type: "kafka", - Name: "Kafka REST Proxy", - Description: "Sends notifications to Kafka Rest Proxy", - Heading: "Kafka settings", - Options: []NotifierOption{ - { - Label: "Kafka REST Proxy", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Hint: If you are directly using v3 APIs hosted on a Confluent Kafka Server, you must append /kafka to the URL here. Example: https://localhost:8082/kafka", - Placeholder: "http://localhost:8082", - PropertyName: "kafkaRestProxy", - Required: true, - }, - { - Label: "Topic", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "topic1", - PropertyName: "kafkaTopic", - Required: true, - }, - { - Label: "Username", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - Required: false, - }, - { - Label: "Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - Description: "The password to use when making a call to the Kafka REST Proxy", - PropertyName: "password", - Required: false, - Secure: true, - }, - { - Label: "API version", - Element: ElementTypeSelect, - InputType: InputTypeText, - Description: "The API version to use when contacting the Kafka REST Server. By default v2 will be used.", - PropertyName: "apiVersion", - Required: false, - SelectOptions: []SelectOption{ - { - Value: "v2", - Label: "v2", - }, - { - Value: "v3", - Label: "v3", - }, - }, - }, - { - Label: "Cluster ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "v3 APIs require a clusterID to be specified.", - Placeholder: "lkc-abcde", - PropertyName: "kafkaClusterId", - Required: true, - ShowWhen: ShowWhen{ - Field: "apiVersion", - Is: "v3", - }, - }, - { - Label: "Description", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated description of the Kafka message", - PropertyName: "description", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { - Label: "Details", - Element: ElementTypeTextArea, - Description: "Custom details to include with the message. You can use template variables.", - PropertyName: "details", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - }, - }, - { - Type: "email", - Name: "Email", - Description: "Sends notifications using Grafana server configured SMTP settings", - Heading: "Email settings", - Options: []NotifierOption{ - { - Label: "Single email", - Description: "Send a single email to all recipients", - Element: ElementTypeCheckbox, - PropertyName: "singleEmail", - }, - { - Label: "Addresses", - Description: "You can enter multiple email addresses using a \";\", \"\\n\" or \",\" separator", - Element: ElementTypeTextArea, - PropertyName: "addresses", - Required: true, - }, - { // New in 8.0. - Label: "Message", - Description: "Optional message. You can use templates to customize this field. Using a custom message will replace the default message", - Element: ElementTypeTextArea, - PropertyName: "message", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - { // New in 9.0. - Label: "Subject", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Optional subject. You can use templates to customize this field", - PropertyName: "subject", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - }, - }, - { - Type: "pagerduty", - Name: "PagerDuty", - Description: "Sends notifications to PagerDuty", - Heading: "PagerDuty settings", - Options: []NotifierOption{ - { - Label: "Integration Key", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Pagerduty Integration Key", - PropertyName: "integrationKey", - Required: true, - Secure: true, - }, - { - Label: "Severity", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "critical", - Description: "Severity of the event. It must be critical, error, warning, info - otherwise, the default is set which is critical. You can use templates", - PropertyName: "severity", - }, - { // New in 8.0. - Label: "Class", - Description: "The class/type of the event, for example 'ping failure' or 'cpu load'", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "class", - }, - { // New in 8.0. - Label: "Component", - Description: "Component of the source machine that is responsible for the event, for example mysql or eth0", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Grafana", - PropertyName: "component", - }, - { // New in 8.0. - Label: "Group", - Description: "Logical grouping of components of a service, for example 'app-stack'", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "group", - }, - { // New in 8.0. - Label: "Summary", - Description: "You can use templates for summary", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "summary", - }, - { // New in 9.4. - Label: "Source", - Description: "The unique location of the affected system, preferably a hostname or FQDN. You can use templates", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "grafana.local", - PropertyName: "source", - }, - { // New in 9.4. - Label: "Client", - Description: "The name of the monitoring client that is triggering this event. You can use templates", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Grafana", - PropertyName: "client", - }, - { // New in 9.4. - Label: "Client URL", - Description: "The URL of the monitoring client that is triggering this event. You can use templates", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "{{ .ExternalURL }}", - PropertyName: "client_url", - }, - { // New in 9.5. - Label: "Details", - Description: "A set of arbitrary key/value pairs that provide further detail about the incident.", - Element: ElementTypeKeyValueMap, - InputType: InputTypeText, - PropertyName: "details", - }, - { // New in 11.1 - Label: "URL", - Description: "The URL to send API requests to", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: alertingPagerduty.DefaultURL, - PropertyName: "url", - }, - }, - }, - { - Type: "victorops", - Name: "VictorOps", - Description: "Sends notifications to VictorOps", - Heading: "VictorOps settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "VictorOps url", - PropertyName: "url", - Required: true, - Secure: true, - }, - { // New in 8.0. - Label: "Message Type", - Element: ElementTypeSelect, - PropertyName: "messageType", - SelectOptions: []SelectOption{ - { - Value: "CRITICAL", - Label: "CRITICAL"}, - { - Value: "WARNING", - Label: "WARNING", - }, - }, - }, - { // New in 9.3. - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated title to display", - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { // New in 9.3. - Label: "Description", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated description of the message", - PropertyName: "description", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - }, - }, - { - Type: "oncall", - Name: "Grafana IRM", - Description: "Sends alerts to Grafana IRM", - Heading: "Grafana IRM settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - Required: true, - }, - { - Label: "HTTP Method", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "POST", - Label: "POST", - }, - { - Value: "PUT", - Label: "PUT", - }, - }, - PropertyName: "httpMethod", - }, - { - Label: "HTTP Basic Authentication - Username", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - }, - { - Label: "HTTP Basic Authentication - Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "password", - Secure: true, - }, - { // New in 9.1 - Label: "Authorization Header - Scheme", - Description: "Optionally provide a scheme for the Authorization Request Header. Default is Bearer.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "authorization_scheme", - Placeholder: "Bearer", - }, - { // New in 9.1 - Label: "Authorization Header - Credentials", - Description: "Credentials for the Authorization Request header. Only one of HTTP Basic Authentication or Authorization Request Header can be set.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "authorization_credentials", - Secure: true, - }, - { // New in 8.0. TODO: How to enforce only numbers? - Label: "Max Alerts", - Description: "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "maxAlerts", - }, - { // New in 9.3. - Label: "Title", - Description: "Templated title of the message.", - Element: ElementTypeTextArea, - InputType: InputTypeText, - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { // New in 9.3. - Label: "Message", - Description: "Custom message. You can use template variables.", - Element: ElementTypeTextArea, - PropertyName: "message", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - }, - }, - { - Type: "pushover", - Name: "Pushover", - Description: "Sends HTTP POST request to the Pushover API", - Heading: "Pushover settings", - Options: []NotifierOption{ - { - Label: "API Token", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Application token", - PropertyName: "apiToken", - Required: true, - Secure: true, - }, - { - Label: "User key(s)", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "comma-separated list", - PropertyName: "userKey", - Required: true, - Secure: true, - }, - { - Label: "Device(s) (optional)", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "comma-separated list; leave empty to send to all devices", - PropertyName: "device", - }, - { - Label: "Alerting priority", - Element: ElementTypeSelect, - SelectOptions: pushoverPriorityOptions, - PropertyName: "priority", - }, - { - Label: "OK priority", - Element: ElementTypeSelect, - SelectOptions: pushoverPriorityOptions, - PropertyName: "okPriority", - }, - { - Description: "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.", - Label: "Retry (Only used for Emergency Priority)", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "minimum 30 seconds", - PropertyName: "retry", - }, - { - Description: "How many seconds the alerting or OK notification will continue to be retried.", - Label: "Expire (Only used for Emergency Priority)", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "maximum 10800 seconds", - PropertyName: "expire", - }, - { - Label: "Alerting sound", - Element: ElementTypeSelect, - SelectOptions: pushoverSoundOptions, - PropertyName: "sound", - }, - { - Label: "OK sound", - Element: ElementTypeSelect, - SelectOptions: pushoverSoundOptions, - PropertyName: "okSound", - }, - { // New in 9.3. - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "title", - }, - { // New in 8.0. - Label: "Message", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - }, - }, - { - Type: "slack", - Name: "Slack", - Description: "Sends notifications to Slack", - Heading: "Slack settings", - Options: []NotifierOption{ - { - Label: "Recipient", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", - PropertyName: "recipient", - Required: true, - DependsOn: "url", - }, - // Logically, this field should be required when not using a webhook, since the Slack API needs a token. - // However, since the UI doesn't allow to say that a field is required or not depending on another field, - // we've gone with the compromise of making this field optional and instead return a validation error - // if it's necessary and missing. - { - Label: "Token", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", - PropertyName: "token", - Secure: true, - Required: true, - DependsOn: "url", - }, - { - Label: "Username", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Set the username for the bot's message", - PropertyName: "username", - }, - { - Label: "Icon emoji", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.", - PropertyName: "icon_emoji", - }, - { - Label: "Icon URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Provide a URL to an image to use as the icon for the bot's message", - PropertyName: "icon_url", - }, - { - Label: "Mention Users", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)", - PropertyName: "mentionUsers", - }, - { - Label: "Mention Groups", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)", - PropertyName: "mentionGroups", - }, - { - Label: "Mention Channel", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "", - Label: "Disabled", - }, - { - Value: "here", - Label: "Every active channel member", - }, - { - Value: "channel", - Label: "Every channel member", - }, - }, - Description: "Mention whole channel or just active members when notifying", - PropertyName: "mentionChannel", - }, - { - Label: "Webhook URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary", - Placeholder: "Slack incoming webhook URL", - PropertyName: "url", - Secure: true, - Required: true, - DependsOn: "token", - }, - { // New in 8.4. - Label: "Endpoint URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Optionally provide a custom Slack message API endpoint for non-webhook requests, default is https://slack.com/api/chat.postMessage", - Placeholder: "Slack endpoint url", - PropertyName: "endpointUrl", - }, - { - Label: "Color", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Templated color of the slack message", - Placeholder: alertingTemplates.DefaultMessageColor, - PropertyName: "color", - }, - { // New in 8.0. - Label: "Title", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Templated title of the slack message", - PropertyName: "title", - Placeholder: `{{ template "slack.default.title" . }}`, - }, - { // New in 8.0. - Label: "Text Body", - Element: ElementTypeTextArea, - Description: "Body of the slack message", - PropertyName: "text", - Placeholder: `{{ template "slack.default.text" . }}`, - }, - }, - }, - { - Type: "sensugo", - Name: "Sensu Go", - Description: "Sends HTTP POST request to a Sensu Go API", - Heading: "Sensu Go Settings", - Options: []NotifierOption{ - { - Label: "Backend URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "http://sensu-api.local:8080", - PropertyName: "url", - Required: true, - }, - { - Label: "API Key", - Element: ElementTypeInput, - InputType: InputTypePassword, - Description: "API key to auth to Sensu Go backend", - PropertyName: "apikey", - Required: true, - Secure: true, - }, - { - Label: "Proxy entity name", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "default", - PropertyName: "entity", - }, - { - Label: "Check name", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "default", - PropertyName: "check", - }, - { - Label: "Handler", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "handler", - }, - { - Label: "Namespace", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "default", - PropertyName: "namespace", - }, - { // New in 8.0. - Label: "Message", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - }, - }, - { - Type: "teams", - Name: "Microsoft Teams", - Description: "Sends notifications using Incoming Webhook connector to Microsoft Teams", - Heading: "Teams settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Teams incoming webhook url", - PropertyName: "url", - Required: true, - }, - { - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated title of the Teams message.", - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { - Label: "Section Title", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Section title for the Teams message. Leave blank for none.", - PropertyName: "sectiontitle", - }, - { // New in 8.0. - Label: "Message", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - }, - }, - { - Type: "telegram", - Name: "Telegram", - Description: "Sends notifications to Telegram", - Heading: "Telegram API settings", - Options: []NotifierOption{ - { - Label: "BOT API Token", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Telegram BOT API Token", - PropertyName: "bottoken", - Required: true, - Secure: true, - }, - { - Label: "Chat ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Integer Telegram Chat Identifier", - PropertyName: "chatid", - Required: true, - }, - { - Label: "Message Thread ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Integer Telegram Message Thread Identifier", - PropertyName: "message_thread_id", - Required: false, - ValidationRule: "-?[0-9]{1,10}", - }, - { // New in 8.0. - Label: "Message", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - { - Label: "Parse Mode", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "None", - Label: "None", - }, - { - Value: "HTML", - Label: "HTML", - }, - { - Value: "Markdown", - Label: "Markdown", - }, - { - Value: "MarkdownV2", - Label: "Markdown V2", - }, - }, - Description: `Mode for parsing entities in the message text. Default is 'HTML'`, - PropertyName: "parse_mode", - }, - { - Label: "Disable Web Page Preview", - Description: "Disables link previews for links in this message", - Element: ElementTypeCheckbox, - PropertyName: "disable_web_page_preview", - }, - { - Label: "Protect Content", - Description: "Protects the contents of the sent message from forwarding and saving", - Element: ElementTypeCheckbox, - PropertyName: "protect_content", - }, - { - Label: "Disable Notification", - Description: "Sends the message silently. Users will receive a notification with no sound.", - Element: ElementTypeCheckbox, - PropertyName: "disable_notification", - }, - }, - }, - { - Type: "webhook", - Name: "Webhook", - Description: "Sends HTTP POST request to a URL", - Heading: "Webhook settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - Required: true, - }, - { - Label: "HTTP Method", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "POST", - Label: "POST", - }, - { - Value: "PUT", - Label: "PUT", - }, - }, - PropertyName: "httpMethod", - }, - { - Label: "HTTP Basic Authentication - Username", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - }, - { - Label: "HTTP Basic Authentication - Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "password", - Secure: true, - }, - { // New in 9.1 - Label: "Authorization Header - Scheme", - Description: "Optionally provide a scheme for the Authorization Request Header. Default is Bearer.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "authorization_scheme", - Placeholder: "Bearer", - }, - { // New in 9.1 - Label: "Authorization Header - Credentials", - Description: "Credentials for the Authorization Request header. Only one of HTTP Basic Authentication or Authorization Request Header can be set.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "authorization_credentials", - Secure: true, - }, - { // New in 12.0. - Label: "Extra Headers", - Description: "Optionally provide extra headers to be used in the request.", - Element: ElementTypeKeyValueMap, - InputType: InputTypeText, - PropertyName: "headers", - }, - { // New in 8.0. TODO: How to enforce only numbers? - Label: "Max Alerts", - Description: "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "maxAlerts", - }, - { // New in 9.3. - Label: "Title", - Description: "Templated title of the message.", - Element: ElementTypeTextArea, - InputType: InputTypeText, - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { // New in 9.3. - Label: "Message", - Description: "Templated message to be used in the payload's \"message\" field.", - Element: ElementTypeTextArea, - PropertyName: "message", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - { // New in 12.0. - Label: "Custom Payload", - Description: "Optionally provide a templated payload. Overrides 'Message' and 'Title' field.", - Element: ElementTypeSubform, - PropertyName: "payload", - SubformOptions: []NotifierOption{ - { - Label: "Payload Template", - Description: "Custom payload template.", - Element: ElementTypeTextArea, - PropertyName: "template", - Placeholder: `{{ template "webhook.default.payload" . }}`, - Required: true, - }, - { - Label: "Payload Variables", - Description: "Optionally provide a variables to be used in the payload template. They will be available in the template as `.Vars.`.", - Element: ElementTypeKeyValueMap, - InputType: InputTypeText, - PropertyName: "vars", - }, - }, - }, - - { - Label: "TLS", - PropertyName: "tlsConfig", - Description: "TLS configuration options", - Element: ElementTypeSubform, - SubformOptions: tlsSubformOptions(), - }, - { - Label: "HMAC Signature", - PropertyName: "hmacConfig", - Description: "HMAC signature configuration options", - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Secret", - Element: ElementTypeInput, - Description: "", - InputType: InputTypeText, - PropertyName: "secret", - Required: true, - Secure: true, - }, - { - Label: "Header", - Element: ElementTypeInput, - Description: "The header in which the HMAC signature will be included.", - InputType: InputTypeText, - PropertyName: "header", - Placeholder: "X-Grafana-Alerting-Signature", - Required: false, - Secure: false, - }, - { - Label: "Timestamp header", - Element: ElementTypeInput, - Description: "If set, the timestamp will be included in the HMAC signature. The value should be the name of the header to use.", - InputType: InputTypeText, - PropertyName: "timestampHeader", - Required: false, - Secure: false, - }, - }, - }, - commonHttpClientOption(), // New in 12.1. - }, - }, - { - Type: "wecom", - Name: "WeCom", - Description: "Send alerts generated by Grafana to WeCom", - Heading: "WeCom settings", - Options: []NotifierOption{ - { - Label: "Webhook URL", - Description: "Required if using GroupRobot", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx", - PropertyName: "url", - Secure: true, - Required: true, - DependsOn: "secret", - }, - { - Label: "Agent ID", - Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#apps create ApiApp", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "1000002", - PropertyName: "agent_id", - Required: true, - DependsOn: "url", - }, - { - Label: "Corp ID", - Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#profile", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "wwxxxxxxxxx", - PropertyName: "corp_id", - Required: true, - DependsOn: "url", - }, - { - Label: "Secret", - Description: "Required if using APIAPP", - Element: ElementTypeInput, - InputType: InputTypePassword, - Placeholder: "secret", - PropertyName: "secret", - Secure: true, - Required: true, - DependsOn: "url", - }, - { - Label: "Message Type", - Element: ElementTypeSelect, - PropertyName: "msgtype", - SelectOptions: []SelectOption{ - { - Value: "text", - Label: "Text", - }, - { - Value: "markdown", - Label: "Markdown", - }, - }, - Placeholder: "Text", - }, - { - Label: "Message", - Description: "Custom WeCom message. You can use template variables.", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - { // New in 9.1. - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated title of the message", - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { - Label: "To User", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "@all", - PropertyName: "touser", - }, - }, - }, - { - Type: "prometheus-alertmanager", - Name: "Alertmanager", - Description: "Sends notifications to Alertmanager", - Heading: "Alertmanager Settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "http://localhost:9093", - PropertyName: "url", - Required: true, - }, - { - Label: "Basic Auth User", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "basicAuthUser", - }, - { - Label: "Basic Auth Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "basicAuthPassword", - Secure: true, - }, - }, - }, - { - Type: "discord", - Name: "Discord", - Heading: "Discord settings", - Description: "Sends notifications to Discord", - Options: []NotifierOption{ - { - Label: "Title", - Description: "Templated title of the message", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "title", - }, - { - Label: "Message Content", - Description: "Mention a group using @ or a user using <@ID> when notifying in a channel", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - { - Label: "Webhook URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Discord webhook URL", - PropertyName: "url", - Required: true, - Secure: true, - }, - { - Label: "Avatar URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "avatar_url", - }, - { - Label: "Use Discord's Webhook Username", - Description: "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", - Element: ElementTypeCheckbox, - PropertyName: "use_discord_username", - }, - }, - }, - { - Type: "googlechat", - Name: "Google Chat", - Description: "Sends notifications to Google Chat via webhooks based on the official JSON message format", - Heading: "Google Chat settings", - Options: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Google Chat incoming webhook url", - PropertyName: "url", - Required: true, - Secure: true, - }, - { - Label: "Title", - Description: "Templated title of the message", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "title", - }, - { - Label: "Message", - Element: ElementTypeTextArea, - Placeholder: alertingTemplates.DefaultMessageEmbed, - PropertyName: "message", - }, - }, - }, - { - Type: "LINE", - Name: "LINE", - Description: "Send notifications to LINE notify", - Heading: "LINE notify settings", - Options: []NotifierOption{ - { - Label: "Token", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "LINE notify token key", - PropertyName: "token", - Required: true, - Secure: true, - }, - { // New in 9.3 - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated title of the message", - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { // New in 9.3 - Label: "Description", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated description of the message", - PropertyName: "description", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - }, - }, - { - Type: "threema", - Name: "Threema Gateway", - Description: "Sends notifications to Threema using Threema Gateway (Basic IDs)", - Heading: "Threema Gateway settings", - Info: "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported." + - "The Threema Gateway ID can be set up at https://gateway.threema.ch/.", - Options: []NotifierOption{ - { - Label: "Gateway ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "*3MAGWID", - Description: "Your 8 character Threema Gateway Basic ID (starting with a *).", - PropertyName: "gateway_id", - Required: true, - ValidationRule: "\\*[0-9A-Z]{7}", - }, - { - Label: "Recipient ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "YOUR3MID", - Description: "The 8 character Threema ID that should receive the alerts.", - PropertyName: "recipient_id", - Required: true, - ValidationRule: "[0-9A-Z]{8}", - }, - { - Label: "API Secret", - Element: ElementTypeInput, - InputType: InputTypeText, - Description: "Your Threema Gateway API secret.", - PropertyName: "api_secret", - Required: true, - Secure: true, - }, - { // New in 9.3 - Label: "Title", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated title of the message.", - PropertyName: "title", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { // New in 9.3 - Label: "Description", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Templated description of the message.", - PropertyName: "description", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - }, - }, - { - Type: "mqtt", - Name: "MQTT", - Description: "Sends notifications to an MQTT broker", - Heading: "MQTT settings", - Info: "The MQTT notifier sends messages to an MQTT broker. The message is sent to the topic specified in the configuration. ", - Options: []NotifierOption{ - { - Label: "Broker URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "tcp://localhost:1883", - Description: "The URL of the MQTT broker.", - PropertyName: "brokerUrl", - Required: true, - }, - { - Label: "Topic", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "grafana/alerts", - Description: "The topic to which the message will be sent.", - PropertyName: "topic", - Required: true, - }, - { - Label: "Message format", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: alertingMqtt.MessageFormatJSON, - Label: "json", - }, - { - Value: alertingMqtt.MessageFormatText, - Label: "text", - }, - }, - InputType: InputTypeText, - Placeholder: "json", - 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, - }, - { - Label: "Client ID", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - Description: "The client ID to use when connecting to the MQTT broker. If blank, a random client ID is used.", - PropertyName: "clientId", - Required: false, - }, - { - 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", - }, - { - Label: "Username", - Description: "The username to use when connecting to the MQTT broker.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "username", - Required: false, - }, - { - Label: "Password", - Description: "The password to use when connecting to the MQTT broker.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "password", - Required: false, - Secure: true, - }, - { - Label: "QoS", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "0", - Label: "At most once (0)", - }, - { - Value: "1", - Label: "At least once (1)", - }, - { - Value: "2", - Label: "Exactly once (2)", - }, - }, - Description: "The quality of service to use when sending the message.", - PropertyName: "qos", - Required: false, - }, - { - Label: "Retain", - Description: "If set to true, the message will be retained by the broker.", - Element: ElementTypeCheckbox, - PropertyName: "retain", - Required: false, - }, - { - Label: "TLS", - PropertyName: "tlsConfig", - Description: "TLS configuration options", - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Disable certificate verification", - Element: ElementTypeCheckbox, - Description: "Do not verify the broker'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 broker'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 broker.", - 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 broker.", - InputType: InputTypeText, - PropertyName: "clientKey", - Required: false, - Secure: true, - }, - }, - }, - }, - }, - { - Type: "opsgenie", - Name: "OpsGenie", - Description: "Sends notifications to OpsGenie", - Heading: "OpsGenie settings", - Options: []NotifierOption{ - { - Label: "API Key", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "OpsGenie API Key", - PropertyName: "apiKey", - Required: true, - Secure: true, - }, - { - Label: "Alert API URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "https://api.opsgenie.com/v2/alerts", - PropertyName: "apiUrl", - Required: true, - }, - { - Label: "Message", - Description: "Alert text limited to 130 characters.", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - PropertyName: "message", - }, - { - Label: "Description", - Description: "A description of the incident.", - Element: ElementTypeTextArea, - PropertyName: "description", - }, - { - Label: "Auto close incidents", - Element: ElementTypeCheckbox, - Description: "Automatically close alerts in OpsGenie once the alert goes back to ok.", - PropertyName: "autoClose", - }, { - Label: "Override priority", - Element: ElementTypeCheckbox, - Description: "Allow the alert priority to be set using the og_priority label.", - PropertyName: "overridePriority", - }, - { - Label: "Send notification tags as", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: alertingOpsgenie.SendTags, - Label: "Tags", - }, - { - Value: alertingOpsgenie.SendDetails, - Label: "Extra Properties", - }, - { - Value: alertingOpsgenie.SendBoth, - Label: "Tags & Extra Properties", - }, - }, - Description: "Send the common annotations to Opsgenie as either Extra Properties, Tags or both", - PropertyName: "sendTagsAs", - }, - // New in 10.3 - { - Label: "Responders", - PropertyName: "responders", - Description: "If the API key belongs to a team, this field is ignored.", - Element: ElementSubformArray, - SubformOptions: []NotifierOption{ - { - Label: "Type", - Description: fmt.Sprintf("%s or a template", strings.Join(alertingOpsgenie.SupportedResponderTypes, ", ")), - Element: ElementTypeInput, - Required: true, - PropertyName: "type", - }, - { - Label: "Name", - Element: ElementTypeInput, - Description: "Name of the responder. Must be specified if ID and Username are empty or if the type is 'teams'.", - PropertyName: "name", - }, - { - Label: "ID", - Element: ElementTypeInput, - Description: "ID of the responder. Must be specified if name and Username are empty.", - PropertyName: "id", - }, - { - Label: "Username", - Element: ElementTypeInput, - Description: "User name of the responder. Must be specified if ID and Name are empty.", - PropertyName: "username", - }, - }, - }, - }, - }, - { - Type: "webex", - Name: "Cisco Webex Teams", - Description: "Sends notifications to Cisco Webex Teams", - Heading: "Webex settings", - Info: "Notifications can be configured for any Cisco Webex Teams", - Options: []NotifierOption{ - { - Label: "Cisco Webex API URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "https://api.ciscospark.com/v1/messages", - Description: "API endpoint at which we'll send webhooks to.", - PropertyName: "api_url", - }, - { - Label: "Room ID", - Description: "The room ID to send messages to.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "GMtOWY0ZGJkNzMyMGFl", - PropertyName: "room_id", - Required: true, - }, - { - Label: "Bot Token", - Description: "Non-expiring access token of the bot that will post messages on our behalf.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: `GMtOWY0ZGJkNzMyMGFl-12535454-123213`, - PropertyName: "bot_token", - Secure: true, - Required: true, - }, - { - Label: "Notification Template", - Description: "Notification template to use. Markdown is supported.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: `{{ template "default.message" . }}`, - PropertyName: "message", - }, - }, - }, - { // Since Grafana 11.1 - Type: "sns", - Name: "AWS SNS", - Description: "Sends notifications to AWS Simple Notification Service", - Heading: "Webex settings", - Options: []NotifierOption{ - { - Label: "The Amazon SNS API URL", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "api_url", - }, - { - Label: "SigV4 Authentication", - Description: "Configures AWS's Signature Verification 4 signing process to sign requests", - Element: ElementTypeSubform, - PropertyName: "sigv4", - SubformOptions: []NotifierOption{ - { - Label: "Region", - Description: "The AWS region. If blank, the region from the default credentials chain is used.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "region", - }, - { - Label: "Access Key", - Description: "The AWS API access key.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "access_key", - Secure: true, - }, - { - Label: "Secret Key", - Description: "The AWS API secret key.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "secret_key", - Secure: true, - }, - { - Label: "Profile", - Description: "Named AWS profile used to authenticate", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "profile", - }, - { - Label: "Role ARN", - Description: "AWS Role ARN, an alternative to using AWS API keys", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "role_arn", - }, - }, - }, - { - Label: "SNS topic ARN", - Description: "If you don't specify this value, you must specify a value for the phone_number or target_arn. If you are using a FIFO SNS topic you should set a message group interval longer than 5 minutes to prevent messages with the same group key being deduplicated by the SNS default deduplication window.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "topic_arn", - }, - { - Label: "Phone number", - Description: "Phone number if message is delivered via SMS in E.164 format. If you don't specify this value, you must specify a value for the topic_arn or target_arn", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: ``, - PropertyName: "phone_number", - Secure: false, - }, - { - Label: "Target ARN", - Description: "The mobile platform endpoint ARN if message is delivered via mobile notifications. If you don't specify this value, you must specify a value for the topic_arn or phone_number", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: ``, - PropertyName: "target_arn", - }, - { - Label: "Subject", - Element: ElementTypeTextArea, - InputType: InputTypeText, - Description: "Optional subject. By default, this field uses the default title template and can be customized with templates and custom messages. It cannot be an empty string", - PropertyName: "subject", - Placeholder: alertingTemplates.DefaultMessageTitleEmbed, - }, - { - Label: "Message", - Description: "Optional message. By default, this field uses the default message template and can be customized with templates and custom messages", - Element: ElementTypeTextArea, - PropertyName: "message", - Placeholder: alertingTemplates.DefaultMessageEmbed, - }, - { - Label: "Attributes", - Description: "SNS message attributes", - Element: ElementTypeKeyValueMap, - InputType: InputTypeText, - PropertyName: "attributes", - }, - }, - }, - { // Since Grafana 11.6 - Type: "jira", - Name: "Jira", - Description: "Creates Jira issues from alerts", - Heading: "Jira settings", - Options: []NotifierOption{ - { - Label: "API URL of Jira instance, including version of API", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "https://grafana.atlassian.net/rest/api/3", - PropertyName: "api_url", - Description: "Supported v2 or v3 APIs", - Required: true, - }, - { - Label: "HTTP Basic Authentication - Username", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "user", - Description: "Username to use for Jira authentication.", - Secure: true, - Required: false, - }, - { - Label: "HTTP Basic Authentication - Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "password", - // Go to https://id.atlassian.com/manage-profile/security/api-tokens to obtain a token. - Description: "Password to use for Jira authentication.", - Secure: true, - Required: false, - }, - { - Label: "Authorization Header - Personal Access Token", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "api_token", - // Go to https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html for how to obtain a token. - Description: "Personal Access Token that is used as a bearer authorization header.", - Secure: true, - Required: false, - }, - { - Label: "Project Key", - Description: "The project key associated with the relevant Jira project", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Grafana", - PropertyName: "project", - Required: true, - }, - { - Label: "Issue Type", - Description: "The type of the Jira issue (e.g., Bug, Task, Story). You can use templates to customize this field.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "Task", - Required: true, - PropertyName: "issue_type", - }, - { - Label: "Summary", - Description: fmt.Sprintf("The summary of the Jira issue. You can use templates to customize this field. Maximum length is %d characters.", jira.MaxSummaryLenRunes), - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: jira.DefaultSummary, - PropertyName: "summary", - }, - { - Label: "Description", - Description: fmt.Sprintf("The description of the Jira issue. You can use templates to customize this field. Maximum length is %d characters.", jira.MaxDescriptionLenRunes), - Element: ElementTypeTextArea, - InputType: InputTypeText, - Placeholder: jira.DefaultDescription, - PropertyName: "description", - }, - { - Label: "Labels", - Description: "Labels to assign to the Jira issue. You can use templates to customize this field.", - Element: ElementStringArray, - Placeholder: "", - PropertyName: "labels", - }, - { - Label: "Priority", - Description: "The priority of the Jira issue (e.g., High, Medium, Low). You can use templates to customize this field.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: jira.DefaultPriority, - PropertyName: "priority", - Required: false, - }, - { - Label: "Resolve Transition", - Description: `Name of the workflow transition to resolve an issue. The target status must have the category "done". If not set, the issue will not be resolved.`, - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "resolve_transition", - Required: false, - }, - { - Label: "Reopen Transition", - Description: `Name of the workflow transition to resolve an issue. The target status must not have the category "done". If not set, the issue will not be reopened.`, - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "reopen_transition", - Required: false, - }, - { - Label: "Reopen Duration", - Description: "Reopen the issue when it is not older than this value in minutes. Otherwise, create a new issue.", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "10m", - PropertyName: "reopen_duration", - }, - { - Label: "\"Won't fix\" Transition", - Description: `If reopen transition is defined, ignore issues with that resolution.`, - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "wont_fix_resolution", - Required: false, - }, - { - Label: "Custom field ID for deduplication", - Description: "Id of the custom field where the deduplication key should be stored. Otherwise, it is added to labels in format 'ALERT($KEY).'", - Element: ElementTypeInput, - InputType: InputTypeText, - Placeholder: "10000", - ValidationRule: "^[0-9]+$", - PropertyName: "dedup_key_field", - }, - { - Label: "Custom Field Data", - Description: "Custom field data to set on the Jira issue.", - Element: ElementTypeKeyValueMap, - InputType: InputTypeText, - Placeholder: "", - PropertyName: "fields", - }, - }, - }, - } -} - -// GetSecretKeysForContactPointType returns settings keys of contact point of the given type that are expected to be secrets. Returns error is contact point type is not known. -func GetSecretKeysForContactPointType(contactPointType string, version NotifierVersion) ([]string, error) { - var notifiers []*NotifierPlugin - if version == V1 { - notifiers = GetAvailableNotifiers() - } - if version == V0mimir1 { - notifiers = getAvailableV0mimir1Notifiers() - } - if version == V0mimir2 { - notifiers = getAvailableV0mimir2Notifiers() - } - for _, n := range notifiers { - if strings.EqualFold(n.Type, contactPointType) { - return getSecretFields("", n.Options), nil - } - } - return nil, fmt.Errorf("no secrets configured for type '%s' of version %s", contactPointType, version) -} - -func getSecretFields(parentPath string, options []NotifierOption) []string { - var secureFields []string - for _, field := range options { - name := field.PropertyName - if parentPath != "" { - name = parentPath + "." + name - } - if field.Secure { - secureFields = append(secureFields, name) - continue - } - if len(field.SubformOptions) > 0 { - secureFields = append(secureFields, getSecretFields(name, field.SubformOptions)...) - } - } - return secureFields -} - -// ConfigForIntegrationType returns the config for the given integration type. Returns error is integration type is not known. -func ConfigForIntegrationType(contactPointTypeOrAlternateType string) (NotifierPluginVersion, error) { - notifiers := GetAvailableNotifiersV2() - for n := range notifiers { - if strings.EqualFold(n.Type, contactPointTypeOrAlternateType) { - return n.GetCurrentVersion(), nil - } - for _, version := range n.Versions { - if version.TypeAlias == "" { - continue - } - if strings.EqualFold(version.TypeAlias, contactPointTypeOrAlternateType) { - return version, nil - } - } - } - return NotifierPluginVersion{}, fmt.Errorf("unknown integration type '%s'", contactPointTypeOrAlternateType) -} - -func GetAvailableNotifiersV2() iter.Seq[*VersionedNotifierPlugin] { - m := make(map[string]*VersionedNotifierPlugin, 24) // we support 24 notifier types - add := func(n []*NotifierPlugin, version NotifierVersion) { - for _, n := range n { - pl, ok := m[n.Type] - if !ok { - pl = &VersionedNotifierPlugin{ - Type: n.Type, - Name: n.Name, - Description: n.Description, - Heading: n.Heading, - Info: n.Info, - CurrentVersion: version, - Versions: make([]NotifierPluginVersion, 0, 2), // usually, there are 2 versions per type - } - m[n.Type] = pl - } - pl.Versions = append(pl.Versions, NotifierPluginVersion{ - Version: version, - // we allow users to create only v1 notifiers - CanCreate: version == V1, - Options: n.Options, - TypeAlias: n.TypeAlias, - Plugin: pl, - }) - } - } - add(GetAvailableNotifiers(), V1) - add(getAvailableV0mimir2Notifiers(), V0mimir2) - add(getAvailableV0mimir1Notifiers(), V0mimir1) - return maps.Values(m) -} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels_mimir.go b/pkg/services/ngalert/notifier/channels_config/available_channels_mimir.go deleted file mode 100644 index e8c89659f57..00000000000 --- a/pkg/services/ngalert/notifier/channels_config/available_channels_mimir.go +++ /dev/null @@ -1,1572 +0,0 @@ -package channels_config - -import ( - promCfg "github.com/prometheus/alertmanager/config" -) - -func getAvailableV0mimir1Notifiers() []*NotifierPlugin { - return []*NotifierPlugin{ - getDiscordMimirNotifier(), - getEmailMimirNotifier(), - getPagerDutyMimirNotifier(), - getSlackMimirNotifier(), - getWebhookMimirNotifier(), - getOpsGenieMimirNotifier(), - getWeChatMimirNotifier(), - getPushoverMimirNotifier(), - getVictorOpsMimirNotifier(), - getAmazonSNSMimirNotifier(), - getTelegramMimirNotifier(), - getWebexMimirNotifier(), - getMicrosoftTeamsMimirNotifier(), - getJiraMimirNotifier(), - } -} - -func getAvailableV0mimir2Notifiers() []*NotifierPlugin { - return []*NotifierPlugin{ - getMicrosoftTeamsV2MimirNotifier(), - } -} - -func getDiscordMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "discord", - Name: "Discord", - Description: "Sends notifications to Discord", - Heading: "Discord settings", - Options: []NotifierOption{ - { - Label: "Webhook URL", - Placeholder: "Discord webhook URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "webhook_url", - Required: true, - Secure: true, - }, - { - Label: "Title", - Description: "Templated title of the message", - Placeholder: promCfg.DefaultDiscordConfig.Title, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - { - Label: "Message Content", - Description: "Mention a group using @ or a user using <@ID> when notifying in a channel", - Placeholder: promCfg.DefaultDiscordConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - v0HttpConfigOption(), - }, - } -} - -func getEmailMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "email", - Name: "Email", - Description: "Send notification over SMTP", - Heading: "Email settings", - Info: "This integration does not use Grafana template", - Options: []NotifierOption{ - { - Label: "To", - Description: "The email address to send notifications to. You can enter multiple addresses using a \",\" separator. You can use templates to customize this field.", - Element: ElementTypeTextArea, - PropertyName: "to", - Required: true, - }, - { - Label: "From", - Description: "The sender address.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "from", - }, - { - Label: "SMTP host", - Description: "The SMTP host and port through which emails are sent.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "smarthost", - }, - { - Label: "Hello", - Description: "The hostname to identify to the SMTP server.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "hello", - }, - { - Label: "Username", - Description: "SMTP authentication information", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "auth_username", - }, - { - Label: "Password", - Description: "SMTP authentication information", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "auth_password", - Secure: true, - }, - { - Label: "Secret", - Description: "SMTP authentication information", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "auth_secret", - Secure: true, - }, - { - Label: "Identity", - Description: "SMTP authentication information", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "auth_identity", - }, - { - Label: "Require TLS", - Description: "The SMTP TLS requirement", - Element: ElementTypeCheckbox, - PropertyName: "require_tls", - }, - { - Label: "Email HTML body", - Description: "The HTML body of the email notification.", - Placeholder: promCfg.DefaultEmailConfig.HTML, - Element: ElementTypeTextArea, - PropertyName: "html", - }, - { - Label: "Email text body", - Description: "The text body of the email notification.", - Placeholder: promCfg.DefaultEmailConfig.Text, - Element: ElementTypeTextArea, - PropertyName: "text", - }, - { - Label: "Headers", - Description: "Further headers email header key/value pairs. Overrides any headers previously set by the notification implementation.", - Element: ElementTypeKeyValueMap, - PropertyName: "headers", - }, - v0TLSConfigOption("tls_config"), - }, - } -} - -func getPagerDutyMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "pagerduty", - Name: "PagerDuty", - Description: "Send notifications to PagerDuty", - Heading: "PagerDuty settings", - Info: "", - Options: []NotifierOption{ - { - Label: "URL", - Description: "The URL to send API requests to", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - Required: true, - }, - { - Label: "Routing key", - Description: "The PagerDuty integration key (when using PagerDuty integration type `Events API v2`)", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "routing_key", - Secure: true, - Required: true, - }, - { - Label: "Service key", - Description: "The PagerDuty integration key (when using PagerDuty integration type `Prometheus`).", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "service_key", - Secure: true, - Required: true, - }, - { - Label: "Client", - Description: "The client identification of the Alertmanager.", - Placeholder: promCfg.DefaultPagerdutyConfig.Client, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "client", - }, - { - Label: "Client URL", - Description: "A backlink to the sender of the notification.", - Placeholder: promCfg.DefaultPagerdutyConfig.ClientURL, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "client_url", - }, - { - Label: "Description", - Description: "A description of the incident.", - Placeholder: promCfg.DefaultPagerdutyConfig.Description, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "description", - }, - { - Label: "Details", - Description: "A set of arbitrary key/value pairs that provide further detail about the incident.", - Element: ElementTypeKeyValueMap, - PropertyName: "details", - }, - { - Label: "Images", - Description: "Images to attach to the incident.", - Element: ElementSubformArray, - PropertyName: "images", - SubformOptions: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "href", - }, - { - Label: "Source", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "source", - }, - { - Label: "Alt", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "alt", - }, - }, - }, - { - Label: "Links", - Description: "Links to attach to the incident.", - Element: ElementSubformArray, - PropertyName: "links", - SubformOptions: []NotifierOption{ - { - Label: "URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "href", - }, - { - Label: "Text", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "text", - }, - }, - }, - { - Label: "Source", - Description: "Unique location of the affected system.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "source", - }, - { - Label: "Severity", - Description: "Severity of the incident.", - Placeholder: "error", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "severity", - }, - { - Label: "Class", - Description: "The class/type of the event.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "class", - }, - { - Label: "Component", - Description: "The part or component of the affected system that is broken.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "component", - }, - { - Label: "Group", - Description: "A cluster or grouping of sources.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "group", - }, - v0HttpConfigOption(), - }, - } -} - -func getSlackMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "slack", - Name: "Slack", - Description: "Send notifications to Slack", - Heading: "Slack settings", - Info: "", - Options: []NotifierOption{ - { - Label: "Webhook URL", - Description: "The Slack webhook URL.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - Secure: true, - Required: true, - }, - { - Label: "Channel", - Description: "The #channel or @user to send notifications to.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "channel", - }, - { - Label: "Username", - Placeholder: promCfg.DefaultSlackConfig.Username, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - }, - { - Label: "Emoji icon", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "icon_emoji", - }, - { - Label: "Icon URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "icon_url", - }, - { - Label: "Names link", - Element: ElementTypeCheckbox, - PropertyName: "link_names", - }, - { - Label: "Callback ID", - Placeholder: promCfg.DefaultSlackConfig.CallbackID, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "callback_id", - }, - { - Label: "Color", - Placeholder: promCfg.DefaultSlackConfig.Color, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "color", - }, - { - Label: "Fallback", - Placeholder: promCfg.DefaultSlackConfig.Fallback, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "fallback", - }, - { - Label: "Footer", - Placeholder: promCfg.DefaultSlackConfig.Footer, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "footer", - }, - { - Label: "Markdown Fields", - Description: "An array of field names that should be formatted by markdown syntax.", - Element: ElementStringArray, - PropertyName: "mrkdwn_in", - }, - { - Label: "Pre-text", - Placeholder: promCfg.DefaultSlackConfig.Pretext, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "pretext", - }, - { - Label: "Short Fields", - Element: ElementTypeCheckbox, - PropertyName: "short_fields", - }, - { - Label: "Message body", - Placeholder: promCfg.DefaultSlackConfig.Text, - Element: ElementTypeTextArea, - PropertyName: "text", - }, - { - Label: "Title", - Placeholder: promCfg.DefaultSlackConfig.Title, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - { - Label: "Title Link", - Placeholder: promCfg.DefaultSlackConfig.TitleLink, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title_link", - }, - { - Label: "Image URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "image_url", - }, - { - Label: "Thumbnail URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "thumb_url", - }, - { - Label: "Actions", - Element: ElementSubformArray, - PropertyName: "actions", - SubformOptions: []NotifierOption{ - { - Label: "Text", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "text", - Required: true, - }, - { - Label: "Type", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "type", - Required: true, - }, - { - Label: "URL", - Description: "Either url or name and value are mandatory.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - }, - { - Label: "Name", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "name", - }, - { - Label: "Value", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "value", - }, - { - Label: "Confirm", - Element: ElementTypeSubform, - PropertyName: "confirm", - SubformOptions: []NotifierOption{ - { - Label: "Text", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "text", - Required: true, - }, - { - Label: "Dismiss text", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "dismiss_text", - }, - { - Label: "OK text", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "ok_text", - }, - { - Label: "Title", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - }, - }, - { - Label: "Style", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "style", - }, - }, - }, - { - Label: "Fields", - Element: ElementSubformArray, - PropertyName: "fields", - SubformOptions: []NotifierOption{ - { - Label: "Title", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - Required: true, - }, - { - Label: "Value", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "value", - Required: true, - }, - { - Label: "Short", - Element: ElementTypeCheckbox, - PropertyName: "short", - }, - }, - }, - v0HttpConfigOption(), - }, - } -} - -func getWebhookMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "webhook", - Name: "Webhook", - Description: "Send notifications to a webhook", - Heading: "Webhook settings", - Info: "", - Options: []NotifierOption{ - { - Label: "URL", - Description: "The endpoint to send HTTP POST requests to.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - Secure: true, - Required: true, - }, - { - Label: "Max alerts", - Description: "The maximum number of alerts to include in a single webhook message. Alerts above this threshold are truncated. When leaving this at its default value of 0, all alerts are included.", - Element: ElementTypeInput, - InputType: "number", - PropertyName: "max_alerts", - ValidationRule: "(^\\d+$|^$)", - }, - { - Label: "Timeout", - Description: "The maximum time to wait for a webhook request to complete, before failing the request and allowing it to be retried. The default value of 0s indicates that no timeout should be applied. NOTE: This will have no effect if set higher than the group_interval.", - Placeholder: "Use duration format, for example: 1.2s, 100ms", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "timeout", - }, - v0HttpConfigOption(), - }, - } -} - -func getOpsGenieMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "opsgenie", - Name: "OpsGenie", - Description: "Send notifications to OpsGenie", - Heading: "OpsGenie settings", - Info: "", - Options: []NotifierOption{ - { - Label: "API key", - Description: "The API key to use when talking to the OpsGenie API.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_key", - Secure: true, - Required: true, - }, - { - Label: "API URL", - Description: "The host to send OpsGenie API requests to.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - Required: true, - }, - { - Label: "Message", - Description: "Alert text limited to 130 characters.", - Placeholder: promCfg.DefaultOpsGenieConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - { - Label: "Description", - Description: "A description of the incident.", - Placeholder: promCfg.DefaultOpsGenieConfig.Description, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "description", - }, - { - Label: "Source", - Description: "A backlink to the sender of the notification.", - Placeholder: promCfg.DefaultOpsGenieConfig.Source, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "source", - }, - { - Label: "Details", - Description: "A set of arbitrary key/value pairs that provide further detail about the incident.", - Element: ElementTypeKeyValueMap, - PropertyName: "details", - }, - { - Label: "Entity", - Description: "Optional field that can be used to specify which domain alert is related to.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "entity", - }, - { - Label: "Actions", - Description: "Comma separated list of actions that will be available for the alert.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "actions", - }, - { - Label: "Tags", - Description: "Comma separated list of tags attached to the notifications.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "tags", - }, - { - Label: "Note", - Description: "Additional alert note.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "note", - }, - { - Label: "Priority", - Description: "Priority level of alert. Possible values are P1, P2, P3, P4, and P5.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "priority", - }, - { - Label: "Update Alerts", - Description: "Whether to update message and description of the alert in OpsGenie if it already exists. By default, the alert is never updated in OpsGenie, the new message only appears in activity log.", - Element: ElementTypeCheckbox, - PropertyName: "update_alerts", - }, - { - Label: "Responders", - Description: "List of responders responsible for notifications.", - Element: ElementSubformArray, - PropertyName: "responders", - SubformOptions: []NotifierOption{ - { - Label: "Type", - Description: "\"team\", \"user\", \"escalation\" or schedule\".", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "type", - }, - { - Label: "ID", - Description: "Exactly one of these fields should be defined.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "id", - }, - { - Label: "Name", - Description: "Exactly one of these fields should be defined.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "name", - }, - { - Label: "Username", - Description: "Exactly one of these fields should be defined.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - }, - }, - }, - v0HttpConfigOption(), - }, - } -} - -func getWeChatMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "wechat", - Name: "WeChat", - Description: "Sends notifications to WeChat", - Heading: "WeChat settings", - Options: []NotifierOption{ - { - Label: "API URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - }, - { - Label: "API Secret", - Description: "The API key to use when talking to the WeChat API", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_secret", - Secure: true, - }, - { - Label: "Corp ID", - Description: "The corp id for authentication", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "corp_id", - }, - { - Label: "Message", - Description: "API request data as defined by the WeChat API", - Placeholder: promCfg.DefaultWechatConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - { - Label: "Message type", - Description: "Type of the message type", - Element: ElementTypeSelect, - PropertyName: "message_type", - Placeholder: "text", - SelectOptions: []SelectOption{ - {Value: "text", Label: "Text"}, - {Value: "markdown", Label: "Markdown"}, - }, - }, - { - Label: "Agent ID", - Placeholder: promCfg.DefaultWechatConfig.AgentID, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "agent_id", - }, - { - Label: "To User", - Placeholder: promCfg.DefaultWechatConfig.ToUser, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "to_user", - }, - { - Label: "To Party", - Placeholder: promCfg.DefaultWechatConfig.ToParty, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "to_party", - }, - { - Label: "To Tag", - Placeholder: promCfg.DefaultWechatConfig.ToTag, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "to_tag", - }, - v0HttpConfigOption(), - }, - } -} - -func getPushoverMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "pushover", - Name: "Pushover", - Description: "Send notifications to Pushover", - Heading: "Pushover settings", - Info: "", - Options: []NotifierOption{ - { - Label: "User key", - Description: "The recipient user’s user key.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "user_key", - Required: true, - Secure: true, - }, - { - Label: "Token", - Description: "Your registered application’s API token, see https://pushover.net/app", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "token", - Required: true, - Secure: true, - }, - { - Label: "Title", - Description: "Notification title.", - Placeholder: promCfg.DefaultPushoverConfig.Title, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - { - Label: "Message", - Description: "Notification message.", - Placeholder: promCfg.DefaultPushoverConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - { - Label: "URL", - Description: "A supplementary URL shown alongside the message.", - Placeholder: promCfg.DefaultPushoverConfig.URL, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url", - }, - { - Label: "URL Title", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "url_title", - }, - { - Label: "Device", - Description: "Optional device to send notification to, see https://pushover.net/api#device", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "device", - }, - { - Label: "Sound", - Description: "Optional sound to use for notification, see https://pushover.net/api#sound", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "sound", - }, - { - Label: "Priority", - Description: "Priority, see https://pushover.net/api#priority", - Placeholder: promCfg.DefaultPushoverConfig.Priority, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "priority", - }, - { - Label: "Retry", - Description: "How often the Pushover servers will send the same notification to the user. Must be at least 30 seconds.", - Placeholder: "1m", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "retry", - }, - { - Label: "Expire", - Description: "How long your notification will continue to be retried for, unless the user acknowledges the notification.", - Placeholder: "1h", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "expire", - }, - { - Label: "TTL", - Description: "The number of seconds before a message expires and is deleted automatically. Examples: 10s, 5m30s, 8h.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "ttl", - }, - { - Label: "HTML", - Description: "Enables HTML formatting of the message.", - Element: ElementTypeCheckbox, - PropertyName: "html", - }, - v0HttpConfigOption(), - }, - } -} - -func getVictorOpsMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "victorops", - Name: "VictorOps", - Description: "Send notifications to VictorOps", - Heading: "VictorOps settings", - Info: "", - Options: []NotifierOption{ - { - Label: "API key", - Description: "The API key to use when talking to the VictorOps API.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_key", - Secure: true, - }, - { - Label: "API URL", - Description: "The VictorOps API URL.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - }, - { - Label: "Routing key", - Description: "A key used to map the alert to a team.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "routing_key", - Required: true, - }, - { - Label: "Message type", - Description: "Describes the behavior of the alert (CRITICAL, WARNING, INFO).", - Placeholder: promCfg.DefaultVictorOpsConfig.MessageType, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message_type", - }, - { - Label: "Entity display name", - Description: "Contains summary of the alerted problem.", - Placeholder: promCfg.DefaultVictorOpsConfig.EntityDisplayName, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "entity_display_name", - }, - { - Label: "State message", - Description: "Contains long explanation of the alerted problem.", - Placeholder: promCfg.DefaultVictorOpsConfig.StateMessage, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "state_message", - }, - { - Label: "Monitoring tool", - Description: "The monitoring tool the state message is from.", - Placeholder: promCfg.DefaultVictorOpsConfig.MonitoringTool, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "monitoring_tool", - }, - { - Label: "Custom Fields", - Description: "A set of arbitrary key/value pairs that provide further detail about the alert.", - Element: ElementTypeKeyValueMap, - PropertyName: "custom_fields", - }, - v0HttpConfigOption(), - }, - } -} - -func getAmazonSNSMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "sns", - Name: "Amazon SNS", - Description: "Sends notifications to Amazon SNS", - Heading: "Amazon SNS settings", - Info: "", - Options: []NotifierOption{ - { - Label: "API URL", - Description: "The Amazon SNS API URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - }, - { - Label: "SigV4 authentication", - Description: "Configures AWS's Signature Verification 4 signing process to sign requests", - Element: ElementTypeSubform, - PropertyName: "sigv4", - SubformOptions: []NotifierOption{ - { - Label: "Region", - Description: "The AWS region. If blank, the region from the default credentials chain is used", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "Region", - }, - { - Label: "Access key", - Description: "The AWS API access_key. If blank the environment variable \"AWS_ACCESS_KEY_ID\" is used", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "AccessKey", - Secure: false, - }, - { - Label: "Secret key", - Description: "The AWS API secret_key. If blank the environment variable \"AWS_ACCESS_SECRET_ID\" is used", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "SecretKey", - Secure: true, - }, - { - Label: "Profile", - Description: "Named AWS profile used to authenticate", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "Profile", - }, - { - Label: "Role ARN", - Description: "AWS Role ARN, an alternative to using AWS API keys", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "RoleARN", - }, - }, - }, - { - Label: "SNS topic ARN", - Description: "If you don't specify this value, you must specify a value for the phone_number or target_arn. If you are using a FIFO SNS topic you should set a message group interval longer than 5 minutes to prevent messages with the same group key being deduplicated by the SNS default deduplication window", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "topic_arn", - }, - { - Label: "Phone number", - Description: "Phone number if message is delivered via SMS in E.164 format. If you don't specify this value, you must specify a value for the topic_arn or target_arn", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "phone_number", - }, - { - Label: "Target ARN", - Description: "The mobile platform endpoint ARN if message is delivered via mobile notifications. If you don't specify this value, you must specify a value for the topic_arn or phone_number", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "target_arn", - }, - { - Label: "Subject", - Description: "Subject line when the message is delivered", - Placeholder: promCfg.DefaultSNSConfig.Subject, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "subject", - }, - { - Label: "Message", - Description: "The message content of the SNS notification", - Placeholder: promCfg.DefaultSNSConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - { - Label: "Attributes", - Description: "SNS message attributes", - Element: ElementTypeKeyValueMap, - PropertyName: "attributes", - }, - v0HttpConfigOption(), - }, - } -} - -func getTelegramMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "telegram", - Name: "Telegram", - Description: "Sends notifications to Telegram", - Heading: "Telegram settings", - Options: []NotifierOption{ - { - Label: "API URL", - Description: "The Telegram API URL", - Placeholder: "https://api.telegram.org", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - }, - { - Label: "Bot token", - Description: "Telegram bot token", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "token", - Required: true, - Secure: true, - }, - { - Label: "Chat ID", - Description: "ID of the chat where to send the messages", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "chat_id", - Required: true, - }, - { - Label: "Message", - Description: "Message template", - Placeholder: promCfg.DefaultTelegramConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - { - Label: "Disable notifications", - Description: "Disable telegram notifications", - Element: ElementTypeCheckbox, - PropertyName: "disable_notifications", - }, - { - Label: "Parse mode", - Description: "Parse mode for telegram message", - Element: ElementTypeSelect, - PropertyName: "parse_mode", - SelectOptions: []SelectOption{ - {Value: "", Label: "None"}, - {Value: "MarkdownV2", Label: "MarkdownV2"}, - {Value: "Markdown", Label: "Markdown"}, - {Value: "HTML", Label: "HTML"}, - }, - }, - v0HttpConfigOption(), - }, - } -} - -func getWebexMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "webex", - Name: "Cisco Webex Teams", - Description: "Sends notifications to Cisco Webex Teams", - Heading: "Cisco Webex Teams settings", - Info: "", - Options: []NotifierOption{ - { - Label: "API URL", - Description: "The Webex Teams API URL", - Placeholder: "https://webexapis.com/v1/messages", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - }, - { - Label: "Room ID", - Description: "ID of the Webex Teams room where to send the messages", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "room_id", - Required: true, - }, - { - Label: "Message", - Description: "Message template", - Placeholder: promCfg.DefaultWebexConfig.Message, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "message", - }, - v0HttpConfigOption(), - }, - } -} - -func getMicrosoftTeamsMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "teams", - TypeAlias: "msteams", - Name: "Microsoft Teams", - Description: "Sends notifications to Microsoft Teams", - Heading: "Microsoft Teams settings", - Options: []NotifierOption{ - { - Label: "Webhook URL", - Description: "The incoming webhook URL.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "webhook_url", - Secure: true, - Required: true, - }, - { - Label: "Title", - Description: "Message title template.", - Placeholder: promCfg.DefaultMSTeamsConfig.Title, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - { - Label: "Summary", - Description: "Message summary template.", - Placeholder: promCfg.DefaultMSTeamsConfig.Summary, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "summary", - }, - { - Label: "Text", - Description: "Message body template.", - Placeholder: promCfg.DefaultMSTeamsConfig.Text, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "text", - }, - v0HttpConfigOption(), - }, - } -} - -func getMicrosoftTeamsV2MimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "teams", - TypeAlias: "msteamsv2", - Name: "Microsoft Teams", - Description: "Sends notifications to Microsoft Teams using new format of adaptive cards", - Heading: "Microsoft Teams settings", - Options: []NotifierOption{ - { - Label: "Webhook URL", - Description: "The incoming webhook URL.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "webhook_url", - Secure: true, - Required: true, - }, - { - Label: "Title", - Description: "Message title template.", - Placeholder: promCfg.DefaultMSTeamsV2Config.Title, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "title", - }, - { - Label: "Text", - Description: "Message body template.", - Placeholder: promCfg.DefaultMSTeamsConfig.Text, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "text", - }, - v0HttpConfigOption(), - }, - } -} - -func getJiraMimirNotifier() *NotifierPlugin { - return &NotifierPlugin{ - Type: "jira", - Name: "Jira", - Description: "Send notifications to Jira Service Management", - Heading: "Jira settings", - Options: []NotifierOption{ - { - Label: "API URL", - Description: "The host to send Jira API requests to", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "api_url", - Required: true, - }, - { - Label: "Project Key", - Description: "The project key where issues are created", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "project", - Required: true, - }, - { - Label: "Issue Type", - Description: "Type of the issue (e.g. Bug)", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "issue_type", - Required: true, - }, - { - Label: "Summary", - Description: "Issue summary template", - Placeholder: promCfg.DefaultJiraConfig.Summary, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "summary", - }, - { - Label: "Description", - Description: "Issue description template", - Placeholder: promCfg.DefaultJiraConfig.Description, - Element: ElementTypeTextArea, - PropertyName: "description", - }, - { - Label: "Labels", - Description: " Labels to be added to the issue", - Element: ElementStringArray, - PropertyName: "labels", - }, - { - Label: "Priority", - Description: "Priority of the issue", - Placeholder: promCfg.DefaultJiraConfig.Priority, - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "priority", - }, - { - Label: "Reopen transition", - Description: "Name of the workflow transition to reopen an issue. The target status should not have the category \"done\"", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "reopen_transition", - }, - { - Label: "Resolve transition", - Description: "Name of the workflow transition to resolve an issue. The target status must have the category \"done\"", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "resolve_transition", - }, - { - Label: "Won't fix resolution", - Description: "If \"Reopen transition\" is defined, ignore issues with that resolution", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "wont_fix_resolution", - }, - { - Label: "Reopen duration", - Description: "If \"Reopen transition\" is defined, reopen the issue when it is not older than this value (rounded down to the nearest minute)", - Placeholder: "Use duration format, for example: 1.2s, 100ms", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "reopen_duration", - }, - { - Label: "Fields", - Description: "Other issue and custom fields", - Element: ElementTypeKeyValueMap, - PropertyName: "fields", - }, - v0HttpConfigOption(), - }, - } -} - -func v0ProxyConfigOptions() []NotifierOption { - return []NotifierOption{ - { - Label: "Proxy URL", - Description: "HTTP proxy server to use to connect to the targets.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "proxy_url", - }, - { - Label: "No Proxy", - Description: "Comma-separated list of domains for which the proxy should not be used.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "no_proxy", - }, - { - Label: "Proxy From Environment", - Description: "Makes use of net/http ProxyFromEnvironment function to determine proxies.", - Element: ElementTypeCheckbox, - PropertyName: "proxy_from_environment", - }, - { - Label: "Proxy Header Environment", - Description: "Headers to send to proxies during CONNECT requests.", - Element: ElementTypeKeyValueMap, - PropertyName: "proxy_connect_header", - }, - } -} - -func v0TLSConfigOption(propertyName string) NotifierOption { - return NotifierOption{ - Label: "TLS config", - Description: "Configures the TLS settings.", - PropertyName: propertyName, - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Server name", - Description: "ServerName extension to indicate the name of the server.", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "server_name", - }, - { - Label: "Skip verify", - Description: "Disable validation of the server certificate.", - Element: ElementTypeCheckbox, - PropertyName: "insecure_skip_verify", - }, - { - Label: "Min TLS Version", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "min_version", - }, - { - Label: "Max TLS Version", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "max_version", - }, - }, - } -} - -func v0HttpConfigOption() NotifierOption { - oauth2ConfigOption := func() NotifierOption { - return NotifierOption{ - Label: "OAuth2", - Description: "Configures the OAuth2 settings.", - PropertyName: "oauth2", - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Client ID", - Description: "The OAuth2 client ID", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "client_id", - Required: true, - }, - { - Label: "Client secret", - Description: "The OAuth2 client secret", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "client_secret", - Required: true, - Secure: true, - }, - { - Label: "Token URL", - Description: "The OAuth2 token exchange URL", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "token_url", - Required: true, - }, - { - Label: "Scopes", - Description: "Comma-separated list of scopes", - Element: ElementStringArray, - PropertyName: "scopes", - }, - { - Label: "Additional parameters", - Element: ElementTypeKeyValueMap, - PropertyName: "endpoint_params", - }, - v0TLSConfigOption("TLSConfig"), - }, - } - } - return NotifierOption{ - Label: "HTTP Config", - Description: "Note that `basic_auth` and `bearer_token` options are mutually exclusive.", - PropertyName: "http_config", - Element: ElementTypeSubform, - SubformOptions: append([]NotifierOption{ - { - Label: "Basic auth", - Description: "Sets the `Authorization` header with the configured username and password.", - PropertyName: "basic_auth", - Element: ElementTypeSubform, - SubformOptions: []NotifierOption{ - { - Label: "Username", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "username", - }, - { - Label: "Password", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "password", - Secure: true, - }, - }, - }, - { - Label: "Authorization", - Description: "The HTTP authorization credentials for the targets.", - Element: ElementTypeSubform, - PropertyName: "authorization", - SubformOptions: []NotifierOption{ - { - Label: "Type", - Element: ElementTypeInput, - InputType: InputTypeText, - PropertyName: "type", - }, - { - Label: "Credentials", - Element: ElementTypeInput, - InputType: InputTypePassword, - PropertyName: "credentials", - Secure: true, - }, - }, - }, - { - Label: "Follow redirects", - Description: "Whether the client should follow HTTP 3xx redirects.", - Element: ElementTypeCheckbox, - PropertyName: "follow_redirects", - }, - { - Label: "Enable HTTP2", - Description: "Whether the client should configure HTTP2.", - Element: ElementTypeCheckbox, - PropertyName: "enable_http2", - }, - { - Label: "HTTP Headers", - Description: "Headers to inject in the requests.", - Element: ElementTypeKeyValueMap, - PropertyName: "http_headers", - }, - }, append( - v0ProxyConfigOptions(), - v0TLSConfigOption("tls_config"), - oauth2ConfigOption())..., - ), - } -} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels_test.go b/pkg/services/ngalert/notifier/channels_config/available_channels_test.go deleted file mode 100644 index 0d3532b99a3..00000000000 --- a/pkg/services/ngalert/notifier/channels_config/available_channels_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package channels_config - -import ( - "encoding/json" - "fmt" - "maps" - "reflect" - "slices" - "strings" - "testing" - - "github.com/grafana/alerting/notify/notifytest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetSecretKeysForContactPointType(t *testing.T) { - httpConfigSecrets := []string{"http_config.authorization.credentials", "http_config.basic_auth.password", "http_config.oauth2.client_secret"} - testCases := []struct { - receiverType string - version NotifierVersion - expectedSecretFields []string - }{ - {receiverType: "dingding", version: V1, expectedSecretFields: []string{"url"}}, - {receiverType: "kafka", version: V1, expectedSecretFields: []string{"password"}}, - {receiverType: "email", version: V1, expectedSecretFields: []string{}}, - {receiverType: "pagerduty", version: V1, expectedSecretFields: []string{"integrationKey"}}, - {receiverType: "victorops", version: V1, expectedSecretFields: []string{"url"}}, - {receiverType: "oncall", version: V1, expectedSecretFields: []string{"password", "authorization_credentials"}}, - {receiverType: "pushover", version: V1, expectedSecretFields: []string{"apiToken", "userKey"}}, - {receiverType: "slack", version: V1, expectedSecretFields: []string{"token", "url"}}, - {receiverType: "sensugo", version: V1, expectedSecretFields: []string{"apikey"}}, - {receiverType: "teams", version: V1, expectedSecretFields: []string{}}, - {receiverType: "telegram", version: V1, expectedSecretFields: []string{"bottoken"}}, - {receiverType: "webhook", version: V1, 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", version: V1, expectedSecretFields: []string{"url", "secret"}}, - {receiverType: "prometheus-alertmanager", version: V1, expectedSecretFields: []string{"basicAuthPassword"}}, - {receiverType: "discord", version: V1, expectedSecretFields: []string{"url"}}, - {receiverType: "googlechat", version: V1, expectedSecretFields: []string{"url"}}, - {receiverType: "LINE", version: V1, expectedSecretFields: []string{"token"}}, - {receiverType: "threema", version: V1, expectedSecretFields: []string{"api_secret"}}, - {receiverType: "opsgenie", version: V1, expectedSecretFields: []string{"apiKey"}}, - {receiverType: "webex", version: V1, expectedSecretFields: []string{"bot_token"}}, - {receiverType: "sns", version: V1, expectedSecretFields: []string{"sigv4.access_key", "sigv4.secret_key"}}, - {receiverType: "mqtt", version: V1, expectedSecretFields: []string{"password", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey"}}, - {receiverType: "jira", version: V1, expectedSecretFields: []string{"user", "password", "api_token"}}, - {receiverType: "victorops", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)}, - {receiverType: "sns", version: V0mimir1, expectedSecretFields: append([]string{"sigv4.SecretKey"}, httpConfigSecrets...)}, - {receiverType: "telegram", version: V0mimir1, expectedSecretFields: append([]string{"token"}, httpConfigSecrets...)}, - {receiverType: "discord", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)}, - {receiverType: "pagerduty", version: V0mimir1, expectedSecretFields: append([]string{"routing_key", "service_key"}, httpConfigSecrets...)}, - {receiverType: "pushover", version: V0mimir1, expectedSecretFields: append([]string{"user_key", "token"}, httpConfigSecrets...)}, - {receiverType: "jira", version: V0mimir1, expectedSecretFields: httpConfigSecrets}, - {receiverType: "opsgenie", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)}, - {receiverType: "teams", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)}, - {receiverType: "teams", version: V0mimir2, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)}, - {receiverType: "email", version: V0mimir1, expectedSecretFields: []string{"auth_password", "auth_secret"}}, - {receiverType: "slack", version: V0mimir1, expectedSecretFields: append([]string{"api_url"}, httpConfigSecrets...)}, - {receiverType: "webex", version: V0mimir1, expectedSecretFields: httpConfigSecrets}, - {receiverType: "wechat", version: V0mimir1, expectedSecretFields: append([]string{"api_secret"}, httpConfigSecrets...)}, - {receiverType: "webhook", version: V0mimir1, expectedSecretFields: append([]string{"url"}, httpConfigSecrets...)}, - } - n := slices.Collect(GetAvailableNotifiersV2()) - type typeWithVersion struct { - Type string - Version NotifierVersion - } - allTypes := make(map[typeWithVersion]struct{}, len(n)) - getKey := func(pluginType string, version NotifierVersion) typeWithVersion { - return typeWithVersion{pluginType, version} - } - for _, p := range n { - for _, v := range p.Versions { - allTypes[getKey(p.Type, v.Version)] = struct{}{} - } - } - - for _, testCase := range testCases { - delete(allTypes, getKey(testCase.receiverType, testCase.version)) - t.Run(fmt.Sprintf("%s-%s", testCase.receiverType, testCase.version), func(t *testing.T) { - got, err := GetSecretKeysForContactPointType(testCase.receiverType, testCase.version) - require.NoError(t, err) - require.ElementsMatch(t, testCase.expectedSecretFields, got) - }) - } - - for it := range allTypes { - t.Run(fmt.Sprintf("%s-%s", it.Type, it.Version), func(t *testing.T) { - got, err := GetSecretKeysForContactPointType(it.Type, it.Version) - require.NoError(t, err) - require.Emptyf(t, got, "secret keys for version %s of %s should be empty", it.Version, it.Type) - }) - } - - require.Emptyf(t, allTypes, "not all types are covered: %s", allTypes) -} - -func TestGetAvailableNotifiersV2(t *testing.T) { - n := slices.Collect(GetAvailableNotifiersV2()) - require.NotEmpty(t, n) - for _, notifier := range n { - t.Run(fmt.Sprintf("integration %s [%s]", notifier.Type, notifier.Name), func(t *testing.T) { - currentVersion := V1 - if notifier.Type == "wechat" { - currentVersion = V0mimir1 - } - t.Run(fmt.Sprintf("current version is %s", currentVersion), func(t *testing.T) { - require.Equal(t, currentVersion, notifier.GetCurrentVersion().Version) - }) - t.Run("should be able to create only v1", func(t *testing.T) { - for _, version := range notifier.Versions { - if version.Version == V1 { - require.True(t, version.CanCreate, "v1 should be able to create") - continue - } - require.False(t, version.CanCreate, "v0 should not be able to create") - } - }) - }) - } -} - -func TestConfigForIntegrationType(t *testing.T) { - t.Run("should return current version for all common types", func(t *testing.T) { - for plugin := range GetAvailableNotifiersV2() { - t.Run(plugin.Type, func(t *testing.T) { - version, err := ConfigForIntegrationType(plugin.Type) - require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type) - assert.Equal(t, version.Plugin, plugin) - assert.Equal(t, version, plugin.GetCurrentVersion()) - }) - } - }) - - t.Run("should return specific version if matched by alias", func(t *testing.T) { - for plugin := range GetAvailableNotifiersV2() { - for _, version := range plugin.Versions { - if version.TypeAlias == "" { - continue - } - t.Run(version.TypeAlias, func(t *testing.T) { - actualVersion, err := ConfigForIntegrationType(version.TypeAlias) - require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type) - assert.Equal(t, version, actualVersion) - }) - } - } - }) - - t.Run("should return error if not known type", func(t *testing.T) { - _, err := ConfigForIntegrationType("unknown") - require.Error(t, err) - }) -} - -func TestTypeUniqueness(t *testing.T) { - knownTypes := make(map[string]struct{}) - for plugin := range GetAvailableNotifiersV2() { - iType := strings.ToLower(plugin.Type) - if _, ok := knownTypes[iType]; ok { - assert.Failf(t, "duplicate plugin type", "plugin type %s", plugin.Type) - } - knownTypes[iType] = struct{}{} - for _, version := range plugin.Versions { - if version.TypeAlias == "" { - continue - } - iType = strings.ToLower(version.TypeAlias) - if _, ok := knownTypes[iType]; ok { - assert.Failf(t, "mimir type duplicates Grafana plugin type", "plugin type %s", iType) - } - knownTypes[iType] = struct{}{} - } - } -} - -func Test_getSecretFields(t *testing.T) { - testCases := []struct { - name string - parentPath string - options []NotifierOption - expectedFields []string - }{ - { - name: "No secure fields", - parentPath: "", - options: []NotifierOption{ - {PropertyName: "field1", Secure: false, SubformOptions: nil}, - {PropertyName: "field2", Secure: false, SubformOptions: nil}, - }, - expectedFields: []string{}, - }, - { - name: "Single secure field", - parentPath: "", - options: []NotifierOption{ - {PropertyName: "field1", Secure: true, SubformOptions: nil}, - {PropertyName: "field2", Secure: false, SubformOptions: nil}, - }, - expectedFields: []string{"field1"}, - }, - { - name: "Secure field in subform", - parentPath: "parent", - options: []NotifierOption{ - {PropertyName: "field1", Secure: true, SubformOptions: nil}, - {PropertyName: "field2", Secure: false, SubformOptions: []NotifierOption{ - {PropertyName: "subfield1", Secure: true, SubformOptions: nil}, - }}, - }, - expectedFields: []string{"parent.field1", "parent.field2.subfield1"}, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - got := getSecretFields(tc.parentPath, tc.options) - require.ElementsMatch(t, got, tc.expectedFields) - }) - } -} - -func TestV0IntegrationsSecrets(t *testing.T) { - // This test ensures that all known integrations' secrets are listed in the schema definition. - notifytest.ForEachIntegrationType(t, func(configType reflect.Type) { - t.Run(configType.Name(), func(t *testing.T) { - integrationType := strings.ToLower(strings.TrimSuffix(configType.Name(), "Config")) - pluginVersion, err := ConfigForIntegrationType(integrationType) - require.NoError(t, err) - if pluginVersion.Version == V1 { - var ok bool - pluginVersion, ok = pluginVersion.Plugin.GetVersion(V0mimir1) - require.True(t, ok) - } - expectedSecrets := pluginVersion.GetSecretFieldsPaths() - var secrets []string - for option := range maps.Keys(notifytest.ValidMimirHTTPConfigs) { - cfg, err := notifytest.GetMimirIntegrationForType(configType, option) - require.NoError(t, err) - data, err := json.Marshal(cfg) - require.NoError(t, err) - m := map[string]any{} - err = json.Unmarshal(data, &m) - require.NoError(t, err) - secrets = append(secrets, getSecrets(m, "")...) - } - secrets = unique(secrets) - t.Log(secrets) - require.ElementsMatch(t, expectedSecrets, secrets) - }) - }) -} - -func unique(slice []string) []string { - keys := make(map[string]struct{}, len(slice)) - list := make([]string, 0, len(slice)) - for _, entry := range slice { - if _, value := keys[entry]; !value { - keys[entry] = struct{}{} - list = append(list, entry) - } - } - return list -} - -func getSecrets(m map[string]any, parent string) []string { - var result []string - for key, val := range m { - str, ok := val.(string) - if ok && str == "" { - result = append(result, parent+key) - } - m, ok := val.(map[string]any) - if ok { - subSecrets := getSecrets(m, parent+key+".") - result = append(result, subSecrets...) - } - } - return result -} diff --git a/pkg/services/ngalert/notifier/channels_config/plugin.go b/pkg/services/ngalert/notifier/channels_config/plugin.go deleted file mode 100644 index a38244989d5..00000000000 --- a/pkg/services/ngalert/notifier/channels_config/plugin.go +++ /dev/null @@ -1,122 +0,0 @@ -package channels_config - -// NotifierPlugin holds meta information about a notifier. -type NotifierPlugin struct { - Type string `json:"type"` - TypeAlias string `json:"typeAlias,omitempty"` - Name string `json:"name"` - Heading string `json:"heading"` - Description string `json:"description"` - Info string `json:"info"` - Options []NotifierOption `json:"options"` -} - -// VersionedNotifierPlugin represents a notifier plugin with multiple versions and detailed configuration options. -// It includes metadata such as type, name, description, and version-specific details. -type VersionedNotifierPlugin struct { - Type string `json:"type"` - CurrentVersion NotifierVersion `json:"currentVersion"` - Name string `json:"name"` - Heading string `json:"heading,omitempty"` - Description string `json:"description,omitempty"` - Info string `json:"info,omitempty"` - Versions []NotifierPluginVersion `json:"versions"` -} - -// GetVersion retrieves a specific version of the notifier plugin by its version string. Returns the version and a boolean indicating success. -func (p VersionedNotifierPlugin) GetVersion(v NotifierVersion) (NotifierPluginVersion, bool) { - for _, version := range p.Versions { - if version.Version == v { - return version, true - } - } - return NotifierPluginVersion{}, false -} - -// GetCurrentVersion retrieves the current version of the notifier plugin based on the CurrentVersion property. -// Panics if the version specified in CurrentVersion is not found in the configured versions. -func (p VersionedNotifierPlugin) GetCurrentVersion() NotifierPluginVersion { - v, ok := p.GetVersion(p.CurrentVersion) - if !ok { - panic("version not found for current version: " + p.CurrentVersion) - } - return v -} - -// NotifierPluginVersion represents a version of a notifier plugin, including configuration options and metadata. -type NotifierPluginVersion struct { - TypeAlias string `json:"typeAlias,omitempty"` - Version NotifierVersion `json:"version"` - CanCreate bool `json:"canCreate"` - Options []NotifierOption `json:"options"` - Info string `json:"info,omitempty"` - Plugin *VersionedNotifierPlugin `json:"-"` -} - -// GetSecretFieldsPaths returns a list of paths for fields marked as secure within the NotifierPluginVersion's options. -func (v NotifierPluginVersion) GetSecretFieldsPaths() []string { - return getSecretFields("", v.Options) -} - -// NotifierOption holds information about options specific for the NotifierPlugin. -type NotifierOption struct { - Element ElementType `json:"element"` - InputType InputType `json:"inputType"` - Label string `json:"label"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - PropertyName string `json:"propertyName"` - SelectOptions []SelectOption `json:"selectOptions"` - ShowWhen ShowWhen `json:"showWhen"` - Required bool `json:"required"` - ValidationRule string `json:"validationRule"` - Secure bool `json:"secure"` - DependsOn string `json:"dependsOn"` - SubformOptions []NotifierOption `json:"subformOptions"` -} - -// ElementType is the type of element that can be rendered in the frontend. -type ElementType string - -const ( - // ElementTypeInput will render an input - ElementTypeInput = "input" - // ElementTypeSelect will render a select - ElementTypeSelect = "select" - // ElementTypeCheckbox will render a checkbox - ElementTypeCheckbox = "checkbox" - // ElementTypeTextArea will render a textarea - ElementTypeTextArea = "textarea" - // ElementTypeKeyValueMap will render inputs to add arbitrary key-value pairs - ElementTypeKeyValueMap = "key_value_map" - // ElementSubformArray will render a sub-form with schema defined in SubformOptions - ElementTypeSubform = "subform" - // ElementSubformArray will render a multiple sub-forms with schema defined in SubformOptions - ElementSubformArray = "subform_array" - // ElementStringArray will render a set of fields to manage an array of strings. - ElementStringArray = "string_array" -) - -// InputType is the type of input that can be rendered in the frontend. -type InputType string - -const ( - // InputTypeText will render a text field in the frontend - InputTypeText = "text" - // InputTypePassword will render a password field in the frontend - InputTypePassword = "password" -) - -// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect. -type SelectOption struct { - Value string `json:"value"` - Label string `json:"label"` -} - -// ShowWhen holds information about when options are dependant on other options. -// Should be used when Element is ElementTypeSelect. -// Does not work for ElementTypeCheckbox. -type ShowWhen struct { - Field string `json:"field"` - Is string `json:"is"` -} diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index c147fa4cec2..51aa05cbc8b 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -8,10 +8,12 @@ import ( "fmt" "strings" + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets" @@ -107,11 +109,11 @@ func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt defini return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err) } - secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type, channels_config.V1) - if err != nil { - return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err) + typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(gr.Type), schema.V1) + if !ok { + return fmt.Errorf("failed to get secret keys for contact point type %s", gr.Type) } - + secretKeys := typeSchema.GetSecretFieldsPaths() secureSettings := gr.SecureSettings if secureSettings == nil { secureSettings = make(map[string]string) diff --git a/pkg/services/ngalert/notifier/email_test.go b/pkg/services/ngalert/notifier/email_test.go index 32606a95c7f..87c73b183f4 100644 --- a/pkg/services/ngalert/notifier/email_test.go +++ b/pkg/services/ngalert/notifier/email_test.go @@ -7,7 +7,7 @@ import ( alertingImages "github.com/grafana/alerting/images" "github.com/grafana/alerting/receivers" - alertingEmail "github.com/grafana/alerting/receivers/email" + alertingEmail "github.com/grafana/alerting/receivers/email/v1" alertingTemplates "github.com/grafana/alerting/templates" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" diff --git a/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go b/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go index 4bf74aa7866..72ce37763aa 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go +++ b/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go @@ -8,13 +8,14 @@ import ( "github.com/grafana/alerting/definition" "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/alerting/receivers/webhook" "github.com/prometheus/alertmanager/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/util" ) func TestReceiverInUse(t *testing.T) { @@ -91,8 +92,9 @@ func TestDeleteReceiver(t *testing.T) { } func TestCreateReceiver(t *testing.T) { - rawCfg := notify.AllKnownConfigsForTesting["webhook"] - cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1")) + rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)] + typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type) + cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1) require.NoError(t, err) settings := map[string]any{} require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings)) @@ -197,8 +199,9 @@ func TestCreateReceiver(t *testing.T) { } func TestUpdateReceiver(t *testing.T) { - rawCfg := notify.AllKnownConfigsForTesting["webhook"] - cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1")) + rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)] + typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type) + cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1) require.NoError(t, err) settings := map[string]any{} require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings)) @@ -297,8 +300,9 @@ func TestUpdateReceiver(t *testing.T) { } func TestGetReceiver(t *testing.T) { - rawCfg := notify.AllKnownConfigsForTesting["webhook"] - cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1")) + rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)] + typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type) + cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1) require.NoError(t, err) settings := map[string]any{} require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings)) diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go index c7ceadad3c8..d349acfa92c 100644 --- a/pkg/services/ngalert/provisioning/contactpoints.go +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -9,6 +9,7 @@ import ( "strings" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" "github.com/prometheus/alertmanager/config" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -17,7 +18,6 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets" @@ -247,11 +247,11 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in if err != nil { return err } - secretKeys, err := channels_config.GetSecretKeysForContactPointType(contactPoint.Type, channels_config.V1) - if err != nil { - return fmt.Errorf("%w: %s", ErrValidation, err.Error()) + typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(contactPoint.Type), schema.V1) + if !ok { + return fmt.Errorf("%w: failed to get secret keys for contact point type %s", ErrValidation, contactPoint.Type) } - for _, secretKey := range secretKeys { + for _, secretKey := range typeSchema.GetSecretFieldsPaths() { secretValue := contactPoint.Settings.Get(secretKey).MustString() if secretValue == apimodels.RedactedValue { contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString()) @@ -522,11 +522,11 @@ func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint, // RemoveSecretsForContactPoint removes all secrets from the contact point's settings and returns them as a map. Returns error if contact point type is not known. func RemoveSecretsForContactPoint(e *apimodels.EmbeddedContactPoint) (map[string]string, error) { s := map[string]string{} - secretKeys, err := channels_config.GetSecretKeysForContactPointType(e.Type, channels_config.V1) - if err != nil { - return nil, err + typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(e.Type), schema.V1) + if !ok { + return nil, fmt.Errorf("failed to get secret keys for contact point type %s", e.Type) } - for _, secretKey := range secretKeys { + for _, secretKey := range typeSchema.GetSecretFieldsPaths() { secretValue, err := extractCaseInsensitive(e.Settings, secretKey) if err != nil { return nil, err diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index 120c90fe4ba..b85dfcd3108 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" "github.com/prometheus/alertmanager/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/secrets" @@ -441,15 +441,14 @@ func TestRemoveSecretsForContactPoint(t *testing.T) { keys := maps.Keys(configs) slices.Sort(keys) for _, integrationType := range keys { - integration := models.IntegrationGen(models.IntegrationMuts.WithValidConfig(integrationType))() + integration := models.IntegrationGen(models.IntegrationMuts.WithValidConfig(schema.IntegrationType(integrationType)))() if f, ok := overrides[integrationType]; ok { f(integration.Settings) } settingsRaw, err := json.Marshal(integration.Settings) require.NoError(t, err) - - expectedFields, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1) - require.NoError(t, err) + typeSchema, _ := notify.GetSchemaVersionForIntegration(schema.IntegrationType(integrationType), schema.V1) + expectedFields := typeSchema.GetSecretFieldsPaths() t.Run(integrationType, func(t *testing.T) { cp := definitions.EmbeddedContactPoint{ diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index b0b3751299e..561e0dd501e 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -54,10 +53,17 @@ func TestIntegrationAvailableChannels(t *testing.T) { require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) - expNotifiers := channels_config.GetAvailableNotifiers() - expJson, err := json.Marshal(expNotifiers) + expectedBytes, err := os.ReadFile(path.Join("test-data", "alert-notifiers-v1-snapshot.json")) require.NoError(t, err) - require.Equal(t, string(expJson), string(b)) + + require.NoError(t, err) + if !assert.JSONEq(t, string(expectedBytes), string(b)) { + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, b, "", " ") + require.NoError(t, err) + err = os.WriteFile(path.Join("test-data", "alert-notifiers-v1-snapshot.json"), prettyJSON.Bytes(), 0o644) + require.NoError(t, err) + } }) t.Run("should return versioned notifiers", func(t *testing.T) { diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 6d486055279..52c41586f31 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -17,11 +17,11 @@ import ( "time" "github.com/grafana/alerting/receivers" - alertingLine "github.com/grafana/alerting/receivers/line" - alertingPushover "github.com/grafana/alerting/receivers/pushover" - alertingSlack "github.com/grafana/alerting/receivers/slack" - alertingTelegram "github.com/grafana/alerting/receivers/telegram" - alertingThreema "github.com/grafana/alerting/receivers/threema" + alertingLine "github.com/grafana/alerting/receivers/line/v1" + alertingPushover "github.com/grafana/alerting/receivers/pushover/v1" + alertingSlack "github.com/grafana/alerting/receivers/slack/v1" + alertingTelegram "github.com/grafana/alerting/receivers/telegram/v1" + alertingThreema "github.com/grafana/alerting/receivers/threema/v1" alertingTemplates "github.com/grafana/alerting/templates" "github.com/prometheus/alertmanager/template" "github.com/prometheus/common/model" diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json new file mode 100644 index 00000000000..59324e97edd --- /dev/null +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json @@ -0,0 +1,4310 @@ +[ + { + "type": "LINE", + "name": "LINE", + "heading": "LINE notify settings", + "description": "Send notifications to LINE notify", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Token", + "description": "", + "placeholder": "LINE notify token key", + "propertyName": "token", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Description", + "description": "Templated description of the message", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "dingding", + "name": "DingDing", + "heading": "DingDing settings", + "description": "Sends HTTP POST request to DingDing", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Message Type", + "description": "", + "placeholder": "", + "propertyName": "msgType", + "selectOptions": [ + { + "value": "link", + "label": "Link" + }, + { + "value": "actionCard", + "label": "ActionCard" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Custom DingDing message. You can use template variables.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "discord", + "name": "Discord", + "heading": "Discord settings", + "description": "Sends notifications to Discord", + "info": "", + "options": [ + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Message Content", + "description": "Mention a group using @ or a user using \u003c@ID\u003e when notifying in a channel", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Webhook URL", + "description": "", + "placeholder": "Discord webhook URL", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Avatar URL", + "description": "", + "placeholder": "", + "propertyName": "avatar_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Use Discord's Webhook Username", + "description": "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", + "placeholder": "", + "propertyName": "use_discord_username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "email", + "name": "Email", + "heading": "Email settings", + "description": "Sends notifications using Grafana server configured SMTP settings", + "info": "", + "options": [ + { + "element": "checkbox", + "inputType": "", + "label": "Single email", + "description": "Send a single email to all recipients", + "placeholder": "", + "propertyName": "singleEmail", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Addresses", + "description": "You can enter multiple email addresses using a \";\", \"\\n\" or \",\" separator", + "placeholder": "", + "propertyName": "addresses", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Optional message. You can use templates to customize this field. Using a custom message will replace the default message", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Subject", + "description": "Optional subject. You can use templates to customize this field", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "subject", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "googlechat", + "name": "Google Chat", + "heading": "Google Chat settings", + "description": "Sends notifications to Google Chat via webhooks based on the official JSON message format", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "Google Chat incoming webhook url", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "jira", + "name": "Jira", + "heading": "Jira settings", + "description": "Creates Jira issues from alerts", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "API URL of Jira instance, including version of API", + "description": "Supported v2 or v3 APIs", + "placeholder": "https://grafana.atlassian.net/rest/api/3", + "propertyName": "api_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "HTTP Basic Authentication - Username", + "description": "Username to use for Jira authentication.", + "placeholder": "", + "propertyName": "user", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "HTTP Basic Authentication - Password", + "description": "Password to use for Jira authentication.", + "placeholder": "", + "propertyName": "password", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "Authorization Header - Personal Access Token", + "description": "Personal Access Token that is used as a bearer authorization header.", + "placeholder": "", + "propertyName": "api_token", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Project Key", + "description": "The project key associated with the relevant Jira project", + "placeholder": "Grafana", + "propertyName": "project", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Issue Type", + "description": "The type of the Jira issue (e.g., Bug, Task, Story). You can use templates to customize this field.", + "placeholder": "Task", + "propertyName": "issue_type", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Summary", + "description": "The summary of the Jira issue. You can use templates to customize this field. Maximum length is 255 characters.", + "placeholder": "{{ template \"jira.default.summary\" . }}", + "propertyName": "summary", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Description", + "description": "The description of the Jira issue. You can use templates to customize this field. Maximum length is 32767 characters.", + "placeholder": "{{ template \"jira.default.description\" . }}", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "string_array", + "inputType": "", + "label": "Labels", + "description": "Labels to assign to the Jira issue. You can use templates to customize this field.", + "placeholder": "", + "propertyName": "labels", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Priority", + "description": "The priority of the Jira issue (e.g., High, Medium, Low). You can use templates to customize this field.", + "placeholder": "{{ template \"jira.default.priority\" . }}", + "propertyName": "priority", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Resolve Transition", + "description": "Name of the workflow transition to resolve an issue. The target status must have the category \"done\". If not set, the issue will not be resolved.", + "placeholder": "", + "propertyName": "resolve_transition", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Reopen Transition", + "description": "Name of the workflow transition to resolve an issue. The target status must not have the category \"done\". If not set, the issue will not be reopened.", + "placeholder": "", + "propertyName": "reopen_transition", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Reopen Duration", + "description": "Reopen the issue when it is not older than this value in minutes. Otherwise, create a new issue.", + "placeholder": "10m", + "propertyName": "reopen_duration", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "\"Won't fix\" Transition", + "description": "If reopen transition is defined, ignore issues with that resolution.", + "placeholder": "", + "propertyName": "wont_fix_resolution", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Custom field ID for deduplication", + "description": "Id of the custom field where the deduplication key should be stored. Otherwise, it is added to labels in format 'ALERT($KEY).'", + "placeholder": "10000", + "propertyName": "dedup_key_field", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "^[0-9]+$", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Custom Field Data", + "description": "Custom field data to set on the Jira issue.", + "placeholder": "", + "propertyName": "fields", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "kafka", + "name": "Kafka REST Proxy", + "heading": "Kafka settings", + "description": "Sends notifications to Kafka Rest Proxy", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Kafka REST Proxy", + "description": "Hint: If you are directly using v3 APIs hosted on a Confluent Kafka Server, you must append /kafka to the URL here. Example: https://localhost:8082/kafka", + "placeholder": "http://localhost:8082", + "propertyName": "kafkaRestProxy", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Topic", + "description": "", + "placeholder": "topic1", + "propertyName": "kafkaTopic", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Username", + "description": "", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "Password", + "description": "The password to use when making a call to the Kafka REST Proxy", + "placeholder": "", + "propertyName": "password", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "text", + "label": "API version", + "description": "The API version to use when contacting the Kafka REST Server. By default v2 will be used.", + "placeholder": "", + "propertyName": "apiVersion", + "selectOptions": [ + { + "value": "v2", + "label": "v2" + }, + { + "value": "v3", + "label": "v3" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Cluster ID", + "description": "v3 APIs require a clusterID to be specified.", + "placeholder": "lkc-abcde", + "propertyName": "kafkaClusterId", + "selectOptions": null, + "showWhen": { + "field": "apiVersion", + "is": "v3" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Description", + "description": "Templated description of the Kafka message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Details", + "description": "Custom details to include with the message. You can use template variables.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "details", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "mqtt", + "name": "MQTT", + "heading": "MQTT settings", + "description": "Sends notifications to an MQTT broker", + "info": "The MQTT notifier sends messages to an MQTT broker. The message is sent to the topic specified in the configuration. ", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Broker URL", + "description": "The URL of the MQTT broker.", + "placeholder": "tcp://localhost:1883", + "propertyName": "brokerUrl", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Topic", + "description": "The topic to which the message will be sent.", + "placeholder": "grafana/alerts", + "propertyName": "topic", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "text", + "label": "Message format", + "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'.", + "placeholder": "json", + "propertyName": "messageFormat", + "selectOptions": [ + { + "value": "json", + "label": "json" + }, + { + "value": "text", + "label": "text" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Client ID", + "description": "The client ID to use when connecting to the MQTT broker. If blank, a random client ID is used.", + "placeholder": "", + "propertyName": "clientId", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "In 'json' Message format, sets the message field of the default JSON payload. In 'text' Message format, defines the entire payload.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Username", + "description": "The username to use when connecting to the MQTT broker.", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Password", + "description": "The password to use when connecting to the MQTT broker.", + "placeholder": "", + "propertyName": "password", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "QoS", + "description": "The quality of service to use when sending the message.", + "placeholder": "", + "propertyName": "qos", + "selectOptions": [ + { + "value": "0", + "label": "At most once (0)" + }, + { + "value": "1", + "label": "At least once (1)" + }, + { + "value": "2", + "label": "Exactly once (2)" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Retain", + "description": "If set to true, the message will be retained by the broker.", + "placeholder": "", + "propertyName": "retain", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "subform", + "inputType": "", + "label": "TLS", + "description": "TLS configuration options", + "placeholder": "", + "propertyName": "tlsConfig", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "checkbox", + "inputType": "", + "label": "Disable certificate verification", + "description": "Do not verify the broker's certificate chain and host name.", + "placeholder": "", + "propertyName": "insecureSkipVerify", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "CA Certificate", + "description": "Certificate in PEM format to use when verifying the broker's certificate chain.", + "placeholder": "", + "propertyName": "caCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Certificate", + "description": "Client certificate in PEM format to use when connecting to the broker.", + "placeholder": "", + "propertyName": "clientCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Key", + "description": "Client key in PEM format to use when connecting to the broker.", + "placeholder": "", + "propertyName": "clientKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + } + ] + } + ] + }, + { + "type": "oncall", + "name": "Grafana IRM", + "heading": "Grafana IRM settings", + "description": "Sends alerts to Grafana IRM", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "HTTP Method", + "description": "", + "placeholder": "", + "propertyName": "httpMethod", + "selectOptions": [ + { + "value": "POST", + "label": "POST" + }, + { + "value": "PUT", + "label": "PUT" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "HTTP Basic Authentication - Username", + "description": "", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "HTTP Basic Authentication - Password", + "description": "", + "placeholder": "", + "propertyName": "password", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Authorization Header - Scheme", + "description": "Optionally provide a scheme for the Authorization Request Header. Default is Bearer.", + "placeholder": "Bearer", + "propertyName": "authorization_scheme", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Authorization Header - Credentials", + "description": "Credentials for the Authorization Request header. Only one of HTTP Basic Authentication or Authorization Request Header can be set.", + "placeholder": "", + "propertyName": "authorization_credentials", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Max Alerts", + "description": "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", + "placeholder": "", + "propertyName": "maxAlerts", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message.", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Custom message. You can use template variables.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "opsgenie", + "name": "OpsGenie", + "heading": "OpsGenie settings", + "description": "Sends notifications to OpsGenie", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "API Key", + "description": "", + "placeholder": "OpsGenie API Key", + "propertyName": "apiKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Alert API URL", + "description": "", + "placeholder": "https://api.opsgenie.com/v2/alerts", + "propertyName": "apiUrl", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Message", + "description": "Alert text limited to 130 characters.", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Description", + "description": "A description of the incident.", + "placeholder": "", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Auto close incidents", + "description": "Automatically close alerts in OpsGenie once the alert goes back to ok.", + "placeholder": "", + "propertyName": "autoClose", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Override priority", + "description": "Allow the alert priority to be set using the og_priority label.", + "placeholder": "", + "propertyName": "overridePriority", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Send notification tags as", + "description": "Send the common annotations to Opsgenie as either Extra Properties, Tags or both", + "placeholder": "", + "propertyName": "sendTagsAs", + "selectOptions": [ + { + "value": "tags", + "label": "Tags" + }, + { + "value": "details", + "label": "Extra Properties" + }, + { + "value": "both", + "label": "Tags \u0026 Extra Properties" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "subform_array", + "inputType": "", + "label": "Responders", + "description": "If the API key belongs to a team, this field is ignored.", + "placeholder": "", + "propertyName": "responders", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "input", + "inputType": "", + "label": "Type", + "description": "team, teams, user, escalation, schedule or a template", + "placeholder": "", + "propertyName": "type", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "", + "label": "Name", + "description": "Name of the responder. Must be specified if ID and Username are empty or if the type is 'teams'.", + "placeholder": "", + "propertyName": "name", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "", + "label": "ID", + "description": "ID of the responder. Must be specified if name and Username are empty.", + "placeholder": "", + "propertyName": "id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "", + "label": "Username", + "description": "User name of the responder. Must be specified if ID and Name are empty.", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + } + ] + }, + { + "type": "pagerduty", + "name": "PagerDuty", + "heading": "PagerDuty settings", + "description": "Sends notifications to PagerDuty", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Integration Key", + "description": "", + "placeholder": "Pagerduty Integration Key", + "propertyName": "integrationKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Severity", + "description": "Severity of the event. It must be critical, error, warning, info - otherwise, the default is set which is critical. You can use templates", + "placeholder": "critical", + "propertyName": "severity", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Class", + "description": "The class/type of the event, for example 'ping failure' or 'cpu load'", + "placeholder": "", + "propertyName": "class", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Component", + "description": "Component of the source machine that is responsible for the event, for example mysql or eth0", + "placeholder": "Grafana", + "propertyName": "component", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Group", + "description": "Logical grouping of components of a service, for example 'app-stack'", + "placeholder": "", + "propertyName": "group", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Summary", + "description": "You can use templates for summary", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "summary", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Source", + "description": "The unique location of the affected system, preferably a hostname or FQDN. You can use templates", + "placeholder": "grafana.local", + "propertyName": "source", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Client", + "description": "The name of the monitoring client that is triggering this event. You can use templates", + "placeholder": "Grafana", + "propertyName": "client", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Client URL", + "description": "The URL of the monitoring client that is triggering this event. You can use templates", + "placeholder": "{{ .ExternalURL }}", + "propertyName": "client_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Details", + "description": "A set of arbitrary key/value pairs that provide further detail about the incident.", + "placeholder": "", + "propertyName": "details", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "The URL to send API requests to", + "placeholder": "https://events.pagerduty.com/v2/enqueue", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "prometheus-alertmanager", + "name": "Alertmanager", + "heading": "Alertmanager Settings", + "description": "Sends notifications to Alertmanager", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "http://localhost:9093", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Basic Auth User", + "description": "", + "placeholder": "", + "propertyName": "basicAuthUser", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "Basic Auth Password", + "description": "", + "placeholder": "", + "propertyName": "basicAuthPassword", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "pushover", + "name": "Pushover", + "heading": "Pushover settings", + "description": "Sends HTTP POST request to the Pushover API", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "API Token", + "description": "", + "placeholder": "Application token", + "propertyName": "apiToken", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "User key(s)", + "description": "", + "placeholder": "comma-separated list", + "propertyName": "userKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Device(s) (optional)", + "description": "", + "placeholder": "comma-separated list; leave empty to send to all devices", + "propertyName": "device", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Alerting priority", + "description": "", + "placeholder": "", + "propertyName": "priority", + "selectOptions": [ + { + "value": "2", + "label": "Emergency" + }, + { + "value": "1", + "label": "High" + }, + { + "value": "0", + "label": "Normal" + }, + { + "value": "-1", + "label": "Low" + }, + { + "value": "-2", + "label": "Lowest" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "OK priority", + "description": "", + "placeholder": "", + "propertyName": "okPriority", + "selectOptions": [ + { + "value": "2", + "label": "Emergency" + }, + { + "value": "1", + "label": "High" + }, + { + "value": "0", + "label": "Normal" + }, + { + "value": "-1", + "label": "Low" + }, + { + "value": "-2", + "label": "Lowest" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Retry (Only used for Emergency Priority)", + "description": "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.", + "placeholder": "minimum 30 seconds", + "propertyName": "retry", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Expire (Only used for Emergency Priority)", + "description": "How many seconds the alerting or OK notification will continue to be retried.", + "placeholder": "maximum 10800 seconds", + "propertyName": "expire", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Alerting sound", + "description": "", + "placeholder": "", + "propertyName": "sound", + "selectOptions": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "pushover", + "label": "Pushover" + }, + { + "value": "bike", + "label": "Bike" + }, + { + "value": "bugle", + "label": "Bugle" + }, + { + "value": "cashregister", + "label": "Cashregister" + }, + { + "value": "classical", + "label": "Classical" + }, + { + "value": "cosmic", + "label": "Cosmic" + }, + { + "value": "falling", + "label": "Falling" + }, + { + "value": "gamelan", + "label": "Gamelan" + }, + { + "value": "incoming", + "label": "Incoming" + }, + { + "value": "intermission", + "label": "Intermission" + }, + { + "value": "magic", + "label": "Magic" + }, + { + "value": "mechanical", + "label": "Mechanical" + }, + { + "value": "pianobar", + "label": "Pianobar" + }, + { + "value": "siren", + "label": "Siren" + }, + { + "value": "spacealarm", + "label": "Spacealarm" + }, + { + "value": "tugboat", + "label": "Tugboat" + }, + { + "value": "alien", + "label": "Alien" + }, + { + "value": "climb", + "label": "Climb" + }, + { + "value": "persistent", + "label": "Persistent" + }, + { + "value": "echo", + "label": "Echo" + }, + { + "value": "updown", + "label": "Updown" + }, + { + "value": "none", + "label": "None" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "OK sound", + "description": "", + "placeholder": "", + "propertyName": "okSound", + "selectOptions": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "pushover", + "label": "Pushover" + }, + { + "value": "bike", + "label": "Bike" + }, + { + "value": "bugle", + "label": "Bugle" + }, + { + "value": "cashregister", + "label": "Cashregister" + }, + { + "value": "classical", + "label": "Classical" + }, + { + "value": "cosmic", + "label": "Cosmic" + }, + { + "value": "falling", + "label": "Falling" + }, + { + "value": "gamelan", + "label": "Gamelan" + }, + { + "value": "incoming", + "label": "Incoming" + }, + { + "value": "intermission", + "label": "Intermission" + }, + { + "value": "magic", + "label": "Magic" + }, + { + "value": "mechanical", + "label": "Mechanical" + }, + { + "value": "pianobar", + "label": "Pianobar" + }, + { + "value": "siren", + "label": "Siren" + }, + { + "value": "spacealarm", + "label": "Spacealarm" + }, + { + "value": "tugboat", + "label": "Tugboat" + }, + { + "value": "alien", + "label": "Alien" + }, + { + "value": "climb", + "label": "Climb" + }, + { + "value": "persistent", + "label": "Persistent" + }, + { + "value": "echo", + "label": "Echo" + }, + { + "value": "updown", + "label": "Updown" + }, + { + "value": "none", + "label": "None" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "sensugo", + "name": "Sensu Go", + "heading": "Sensu Go Settings", + "description": "Sends HTTP POST request to a Sensu Go API", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Backend URL", + "description": "", + "placeholder": "http://sensu-api.local:8080", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "API Key", + "description": "API key to auth to Sensu Go backend", + "placeholder": "", + "propertyName": "apikey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Proxy entity name", + "description": "", + "placeholder": "default", + "propertyName": "entity", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Check name", + "description": "", + "placeholder": "default", + "propertyName": "check", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Handler", + "description": "", + "placeholder": "", + "propertyName": "handler", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Namespace", + "description": "", + "placeholder": "default", + "propertyName": "namespace", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "slack", + "name": "Slack", + "heading": "Slack settings", + "description": "Sends notifications to Slack", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Recipient", + "description": "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", + "placeholder": "", + "propertyName": "recipient", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "url", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Token", + "description": "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", + "placeholder": "", + "propertyName": "token", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "url", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Username", + "description": "Set the username for the bot's message", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Icon emoji", + "description": "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.", + "placeholder": "", + "propertyName": "icon_emoji", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Icon URL", + "description": "Provide a URL to an image to use as the icon for the bot's message", + "placeholder": "", + "propertyName": "icon_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Mention Users", + "description": "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)", + "placeholder": "", + "propertyName": "mentionUsers", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Mention Groups", + "description": "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)", + "placeholder": "", + "propertyName": "mentionGroups", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Mention Channel", + "description": "Mention whole channel or just active members when notifying", + "placeholder": "", + "propertyName": "mentionChannel", + "selectOptions": [ + { + "value": "", + "label": "Disabled" + }, + { + "value": "here", + "label": "Every active channel member" + }, + { + "value": "channel", + "label": "Every channel member" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Webhook URL", + "description": "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary", + "placeholder": "Slack incoming webhook URL", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "token", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Endpoint URL", + "description": "Optionally provide a custom Slack message API endpoint for non-webhook requests, default is https://slack.com/api/chat.postMessage", + "placeholder": "Slack endpoint url", + "propertyName": "endpointUrl", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Color", + "description": "Templated color of the slack message", + "placeholder": "{{ if eq .Status \"firing\" }}#D63232{{ else }}#36a64f{{ end }}", + "propertyName": "color", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Title", + "description": "Templated title of the slack message", + "placeholder": "{{ template \"slack.default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Text Body", + "description": "Body of the slack message", + "placeholder": "{{ template \"slack.default.text\" . }}", + "propertyName": "text", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "sns", + "name": "AWS SNS", + "heading": "AWS SNS settings", + "description": "Sends notifications to AWS Simple Notification Service", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "The Amazon SNS API URL", + "description": "", + "placeholder": "", + "propertyName": "api_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "subform", + "inputType": "", + "label": "SigV4 Authentication", + "description": "Configures AWS's Signature Verification 4 signing process to sign requests", + "placeholder": "", + "propertyName": "sigv4", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "input", + "inputType": "text", + "label": "Region", + "description": "The AWS region. If blank, the region from the default credentials chain is used.", + "placeholder": "", + "propertyName": "region", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Access Key", + "description": "The AWS API access key.", + "placeholder": "", + "propertyName": "access_key", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Secret Key", + "description": "The AWS API secret key.", + "placeholder": "", + "propertyName": "secret_key", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Profile", + "description": "Named AWS profile used to authenticate", + "placeholder": "", + "propertyName": "profile", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Role ARN", + "description": "AWS Role ARN, an alternative to using AWS API keys", + "placeholder": "", + "propertyName": "role_arn", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "element": "input", + "inputType": "text", + "label": "SNS topic ARN", + "description": "If you don't specify this value, you must specify a value for the phone_number or target_arn. If you are using a FIFO SNS topic you should set a message group interval longer than 5 minutes to prevent messages with the same group key being deduplicated by the SNS default deduplication window.", + "placeholder": "", + "propertyName": "topic_arn", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Phone number", + "description": "Phone number if message is delivered via SMS in E.164 format. If you don't specify this value, you must specify a value for the topic_arn or target_arn", + "placeholder": "", + "propertyName": "phone_number", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Target ARN", + "description": "The mobile platform endpoint ARN if message is delivered via mobile notifications. If you don't specify this value, you must specify a value for the topic_arn or phone_number", + "placeholder": "", + "propertyName": "target_arn", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Subject", + "description": "Optional subject. By default, this field uses the default title template and can be customized with templates and custom messages. It cannot be an empty string", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "subject", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Optional message. By default, this field uses the default message template and can be customized with templates and custom messages", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Attributes", + "description": "SNS message attributes", + "placeholder": "", + "propertyName": "attributes", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "teams", + "name": "Microsoft Teams", + "heading": "Teams settings", + "description": "Sends notifications using Incoming Webhook connector to Microsoft Teams", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "Teams incoming webhook url", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the Teams message.", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Section Title", + "description": "Section title for the Teams message. Leave blank for none.", + "placeholder": "", + "propertyName": "sectiontitle", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "telegram", + "name": "Telegram", + "heading": "Telegram API settings", + "description": "Sends notifications to Telegram", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "BOT API Token", + "description": "", + "placeholder": "Telegram BOT API Token", + "propertyName": "bottoken", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Chat ID", + "description": "Integer Telegram Chat Identifier", + "placeholder": "", + "propertyName": "chatid", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Message Thread ID", + "description": "Integer Telegram Message Thread Identifier", + "placeholder": "", + "propertyName": "message_thread_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "-?[0-9]{1,10}", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Parse Mode", + "description": "Mode for parsing entities in the message text. Default is 'HTML'", + "placeholder": "", + "propertyName": "parse_mode", + "selectOptions": [ + { + "value": "None", + "label": "None" + }, + { + "value": "HTML", + "label": "HTML" + }, + { + "value": "Markdown", + "label": "Markdown" + }, + { + "value": "MarkdownV2", + "label": "Markdown V2" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Disable Web Page Preview", + "description": "Disables link previews for links in this message", + "placeholder": "", + "propertyName": "disable_web_page_preview", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Protect Content", + "description": "Protects the contents of the sent message from forwarding and saving", + "placeholder": "", + "propertyName": "protect_content", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Disable Notification", + "description": "Sends the message silently. Users will receive a notification with no sound.", + "placeholder": "", + "propertyName": "disable_notification", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "threema", + "name": "Threema Gateway", + "heading": "Threema Gateway settings", + "description": "Sends notifications to Threema using Threema Gateway (Basic IDs)", + "info": "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported.The Threema Gateway ID can be set up at https://gateway.threema.ch/.", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Gateway ID", + "description": "Your 8 character Threema Gateway Basic ID (starting with a *).", + "placeholder": "*3MAGWID", + "propertyName": "gateway_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "\\*[0-9A-Z]{7}", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Recipient ID", + "description": "The 8 character Threema ID that should receive the alerts.", + "placeholder": "YOUR3MID", + "propertyName": "recipient_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "[0-9A-Z]{8}", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "API Secret", + "description": "Your Threema Gateway API secret.", + "placeholder": "", + "propertyName": "api_secret", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message.", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Description", + "description": "Templated description of the message.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "victorops", + "name": "VictorOps", + "heading": "VictorOps settings", + "description": "Sends notifications to VictorOps", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "VictorOps url", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Message Type", + "description": "", + "placeholder": "", + "propertyName": "messageType", + "selectOptions": [ + { + "value": "CRITICAL", + "label": "CRITICAL" + }, + { + "value": "WARNING", + "label": "WARNING" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title to display", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Description", + "description": "Templated description of the message", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "description", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "webex", + "name": "Cisco Webex Teams", + "heading": "Webex settings", + "description": "Sends notifications to Cisco Webex Teams", + "info": "Notifications can be configured for any Cisco Webex Teams", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Cisco Webex API URL", + "description": "API endpoint at which we'll send webhooks to.", + "placeholder": "https://api.ciscospark.com/v1/messages", + "propertyName": "api_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Room ID", + "description": "The room ID to send messages to.", + "placeholder": "GMtOWY0ZGJkNzMyMGFl", + "propertyName": "room_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Bot Token", + "description": "Non-expiring access token of the bot that will post messages on our behalf.", + "placeholder": "GMtOWY0ZGJkNzMyMGFl-12535454-123213", + "propertyName": "bot_token", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Notification Template", + "description": "Notification template to use. Markdown is supported.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "type": "webhook", + "name": "Webhook", + "heading": "Webhook settings", + "description": "Sends HTTP POST request to a URL", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "URL", + "description": "", + "placeholder": "", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "HTTP Method", + "description": "", + "placeholder": "", + "propertyName": "httpMethod", + "selectOptions": [ + { + "value": "POST", + "label": "POST" + }, + { + "value": "PUT", + "label": "PUT" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "HTTP Basic Authentication - Username", + "description": "", + "placeholder": "", + "propertyName": "username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "HTTP Basic Authentication - Password", + "description": "", + "placeholder": "", + "propertyName": "password", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Authorization Header - Scheme", + "description": "Optionally provide a scheme for the Authorization Request Header. Default is Bearer.", + "placeholder": "Bearer", + "propertyName": "authorization_scheme", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Authorization Header - Credentials", + "description": "Credentials for the Authorization Request header. Only one of HTTP Basic Authentication or Authorization Request Header can be set.", + "placeholder": "", + "propertyName": "authorization_credentials", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Extra Headers", + "description": "Optionally provide extra headers to be used in the request.", + "placeholder": "", + "propertyName": "headers", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Max Alerts", + "description": "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", + "placeholder": "", + "propertyName": "maxAlerts", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message.", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Templated message to be used in the payload's \"message\" field.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "subform", + "inputType": "", + "label": "Custom Payload", + "description": "Optionally provide a templated payload. Overrides 'Message' and 'Title' field.", + "placeholder": "", + "propertyName": "payload", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "textarea", + "inputType": "", + "label": "Payload Template", + "description": "Custom payload template.", + "placeholder": "{{ template \"webhook.default.payload\" . }}", + "propertyName": "template", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Payload Variables", + "description": "Optionally provide a variables to be used in the payload template. They will be available in the template as `.Vars.\u003cvariable_name\u003e`.", + "placeholder": "", + "propertyName": "vars", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "element": "subform", + "inputType": "", + "label": "TLS", + "description": "TLS configuration options", + "placeholder": "", + "propertyName": "tlsConfig", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "checkbox", + "inputType": "", + "label": "Disable certificate verification", + "description": "Do not verify the server's certificate chain and host name.", + "placeholder": "", + "propertyName": "insecureSkipVerify", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "CA Certificate", + "description": "Certificate in PEM format to use when verifying the server's certificate chain.", + "placeholder": "", + "propertyName": "caCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Certificate", + "description": "Client certificate in PEM format to use when connecting to the server.", + "placeholder": "", + "propertyName": "clientCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Key", + "description": "Client key in PEM format to use when connecting to the server.", + "placeholder": "", + "propertyName": "clientKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "element": "subform", + "inputType": "", + "label": "HMAC Signature", + "description": "HMAC signature configuration options", + "placeholder": "", + "propertyName": "hmacConfig", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "input", + "inputType": "text", + "label": "Secret", + "description": "", + "placeholder": "", + "propertyName": "secret", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Header", + "description": "The header in which the HMAC signature will be included.", + "placeholder": "X-Grafana-Alerting-Signature", + "propertyName": "header", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Timestamp header", + "description": "If set, the timestamp will be included in the HMAC signature. The value should be the name of the header to use.", + "placeholder": "", + "propertyName": "timestampHeader", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "element": "subform", + "inputType": "", + "label": "HTTP Config", + "description": "Common HTTP client options.", + "placeholder": "", + "propertyName": "http_config", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "subform", + "inputType": "", + "label": "OAuth2", + "description": "OAuth2 configuration options", + "placeholder": "", + "propertyName": "oauth2", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "input", + "inputType": "text", + "label": "Token URL", + "description": "URL for the access token endpoint.", + "placeholder": "", + "propertyName": "token_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Client ID", + "description": "Client ID to use when authenticating.", + "placeholder": "", + "propertyName": "client_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Client Secret", + "description": "Client secret to use when authenticating.", + "placeholder": "", + "propertyName": "client_secret", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "string_array", + "inputType": "", + "label": "Scopes", + "description": "Optional scopes to request when obtaining an access token.", + "placeholder": "", + "propertyName": "scopes", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "", + "label": "Endpoint Parameters", + "description": "Optional parameters to append to the access token request.", + "placeholder": "", + "propertyName": "endpoint_params", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "subform", + "inputType": "", + "label": "TLS", + "description": "Optional TLS configuration options for OAuth2 requests.", + "placeholder": "", + "propertyName": "tls_config", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "checkbox", + "inputType": "", + "label": "Disable certificate verification", + "description": "Do not verify the server's certificate chain and host name.", + "placeholder": "", + "propertyName": "insecureSkipVerify", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "CA Certificate", + "description": "Certificate in PEM format to use when verifying the server's certificate chain.", + "placeholder": "", + "propertyName": "caCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Certificate", + "description": "Client certificate in PEM format to use when connecting to the server.", + "placeholder": "", + "propertyName": "clientCertificate", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Client Key", + "description": "Client key in PEM format to use when connecting to the server.", + "placeholder": "", + "propertyName": "clientKey", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": true, + "dependsOn": "", + "subformOptions": null + } + ] + }, + { + "element": "subform", + "inputType": "", + "label": "Proxy Config", + "description": "Optional proxy configuration.", + "placeholder": "", + "propertyName": "proxy_config", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": [ + { + "element": "input", + "inputType": "text", + "label": "Proxy URL", + "description": "HTTP proxy server to use to connect to the targets.", + "placeholder": "https://proxy.example.com", + "propertyName": "proxy_url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "Proxy from environment", + "description": "Use environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.", + "placeholder": "", + "propertyName": "proxy_from_environment", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "No Proxy", + "description": "Comma-separated list of addresses that should not use a proxy.", + "placeholder": "example.com,1.2.3.4", + "propertyName": "no_proxy", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "key_value_map", + "inputType": "text", + "label": "Proxy Connect Header", + "description": "Optional headers to send to proxies during CONNECT requests.", + "placeholder": "", + "propertyName": "proxy_connect_header", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "wecom", + "name": "WeCom", + "heading": "WeCom settings", + "description": "Send alerts generated by Grafana to WeCom", + "info": "", + "options": [ + { + "element": "input", + "inputType": "text", + "label": "Webhook URL", + "description": "Required if using GroupRobot", + "placeholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx", + "propertyName": "url", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "secret", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Agent ID", + "description": "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#apps create ApiApp", + "placeholder": "1000002", + "propertyName": "agent_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "url", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Corp ID", + "description": "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#profile", + "placeholder": "wwxxxxxxxxx", + "propertyName": "corp_id", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": false, + "dependsOn": "url", + "subformOptions": null + }, + { + "element": "input", + "inputType": "password", + "label": "Secret", + "description": "Required if using APIAPP", + "placeholder": "secret", + "propertyName": "secret", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": true, + "validationRule": "", + "secure": true, + "dependsOn": "url", + "subformOptions": null + }, + { + "element": "select", + "inputType": "", + "label": "Message Type", + "description": "", + "placeholder": "Text", + "propertyName": "msgtype", + "selectOptions": [ + { + "value": "text", + "label": "Text" + }, + { + "value": "markdown", + "label": "Markdown" + } + ], + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "Custom WeCom message. You can use template variables.", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "textarea", + "inputType": "text", + "label": "Title", + "description": "Templated title of the message", + "placeholder": "{{ template \"default.title\" . }}", + "propertyName": "title", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "To User", + "description": "", + "placeholder": "@all", + "propertyName": "touser", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + } + ] + } +] \ No newline at end of file diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json index fbf9e0fad82..cd5ed8b8f96 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json @@ -7,6 +7,7 @@ "description": "Send notifications to LINE notify", "versions": [ { + "typeAlias": "line", "version": "v1", "canCreate": true, "options": [ @@ -8143,7 +8144,7 @@ "type": "sns", "currentVersion": "v1", "name": "AWS SNS", - "heading": "Webex settings", + "heading": "AWS SNS settings", "description": "Sends notifications to AWS Simple Notification Service", "versions": [ { diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 1d065aab2fd..1d34b5106c8 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/grafana/alerting/receivers/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -37,7 +38,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/tests/api/alerting" @@ -1311,12 +1311,14 @@ func TestIntegrationCRUD(t *testing.T) { require.Equal(t, receiver, get) t.Run("should return secrets in secureFields but not settings", func(t *testing.T) { for _, integration := range get.Spec.Integrations { + integrationType := schema.IntegrationType(integration.Type) t.Run(integration.Type, func(t *testing.T) { expected := notify.AllKnownConfigsForTesting[strings.ToLower(integration.Type)] var fields map[string]any require.NoError(t, json.Unmarshal([]byte(expected.Config), &fields)) - secretFields, err := channels_config.GetSecretKeysForContactPointType(integration.Type, channels_config.V1) - require.NoError(t, err) + typeSchema, ok := notify.GetSchemaVersionForIntegration(integrationType, schema.V1) + require.True(t, ok) + secretFields := typeSchema.GetSecretFieldsPaths() for _, field := range secretFields { if _, ok := fields[field]; !ok { // skip field that is not in the original setting continue From c66209bca8985efe834d98fda6af649bf13f32a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Fri, 26 Sep 2025 15:47:03 +0200 Subject: [PATCH 22/31] feat(unified-storage): no SQL fallback for dashboards/folders in Mode 5 (#111672) --- pkg/storage/unified/client.go | 14 +++ pkg/storage/unified/federated/client.go | 6 +- pkg/storage/unified/federated/stats.go | 20 ++-- .../{federatedtests => }/stats_test.go | 110 ++++++++++++++++-- 4 files changed, 133 insertions(+), 17 deletions(-) rename pkg/storage/unified/federated/{federatedtests => }/stats_test.go (63%) diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 9d59f880aef..03cab8c6df4 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/dskit/grpcclient" "github.com/grafana/dskit/middleware" "github.com/grafana/dskit/services" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" @@ -66,10 +67,23 @@ func ProvideUnifiedStorageClient(opts *Options, BlobThresholdBytes: apiserverCfg.Key("blob_threshold_bytes").MustInt(options.BlobThresholdDefault), }, opts.Cfg, opts.Features, opts.DB, opts.Tracer, opts.Reg, opts.Authzc, opts.Docs, storageMetrics, indexMetrics, opts.SecureValues) if err == nil { + // Decide whether to disable SQL fallback stats per resource in Mode 5. + // Otherwise we would still try to query the legacy SQL database in Mode 5. + var disableDashboardsFallback, disableFoldersFallback bool + if opts.Cfg != nil { + // String are static here, so we don't need to import the packages. + foldersMode := opts.Cfg.UnifiedStorage["folders.folder.grafana.app"].DualWriterMode + disableFoldersFallback = foldersMode == grafanarest.Mode5 + dashboardsMode := opts.Cfg.UnifiedStorage["dashboards.dashboard.grafana.app"].DualWriterMode + disableDashboardsFallback = dashboardsMode == grafanarest.Mode5 + } + // Used to get the folder stats client = federated.NewFederatedClient( client, // The original legacysql.NewDatabaseProvider(opts.DB), + disableDashboardsFallback, + disableFoldersFallback, ) } diff --git a/pkg/storage/unified/federated/client.go b/pkg/storage/unified/federated/client.go index 8ca3a32bbe3..9de0479863d 100644 --- a/pkg/storage/unified/federated/client.go +++ b/pkg/storage/unified/federated/client.go @@ -10,11 +10,13 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) -func NewFederatedClient(base resource.ResourceClient, sql legacysql.LegacyDatabaseProvider) resource.ResourceClient { +func NewFederatedClient(base resource.ResourceClient, sql legacysql.LegacyDatabaseProvider, disableDashboardsFallback bool, disableFoldersFallback bool) resource.ResourceClient { return &federatedClient{ ResourceClient: base, stats: &LegacyStatsGetter{ - SQL: sql, + SQL: sql, + DisableSQLFallbackDashboards: disableDashboardsFallback, + DisableSQLFallbackFolders: disableFoldersFallback, }, } } diff --git a/pkg/storage/unified/federated/stats.go b/pkg/storage/unified/federated/stats.go index 40bf81b87ea..40721caa1d0 100644 --- a/pkg/storage/unified/federated/stats.go +++ b/pkg/storage/unified/federated/stats.go @@ -13,7 +13,9 @@ import ( // Read stats from legacy SQL type LegacyStatsGetter struct { - SQL legacysql.LegacyDatabaseProvider + SQL legacysql.LegacyDatabaseProvider + DisableSQLFallbackDashboards bool + DisableSQLFallbackFolders bool } func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) { @@ -64,15 +66,19 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resourcepb.Resourc } // Legacy dashboard table - err = fn("dashboard", "org_id=? AND folder_uid=? AND is_folder=false", group, "dashboards", true) - if err != nil { - return err + if !s.DisableSQLFallbackDashboards { + err = fn("dashboard", "org_id=? AND folder_uid=? AND is_folder=false", group, "dashboards", true) + if err != nil { + return err + } } // Legacy folder table - err = fn("folder", "org_id=? AND parent_uid=?", group, "folders", true) - if err != nil { - return err + if !s.DisableSQLFallbackFolders { + err = fn("folder", "org_id=? AND parent_uid=?", group, "folders", true) + if err != nil { + return err + } } // Legacy library_elements table diff --git a/pkg/storage/unified/federated/federatedtests/stats_test.go b/pkg/storage/unified/federated/stats_test.go similarity index 63% rename from pkg/storage/unified/federated/federatedtests/stats_test.go rename to pkg/storage/unified/federated/stats_test.go index 9bdbc097dc1..3fa1c56a55e 100644 --- a/pkg/storage/unified/federated/federatedtests/stats_test.go +++ b/pkg/storage/unified/federated/stats_test.go @@ -1,4 +1,4 @@ -package federatedtests +package federated import ( "context" @@ -22,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/legacysql" - "github.com/grafana/grafana/pkg/storage/unified/federated" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" @@ -32,7 +31,6 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -// tests stats are correctly reported from legacy tables func TestIntegrationDirectSQLStats(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -44,7 +42,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) { fStore := folderimpl.ProvideStore(db) tempUser := &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{}} - // legacy expects the folder to be in both the dashboards and folder tables folder1UID := "test1" now := time.Now() dashFolder1 := dashboards.NewDashboardFolder("test1") @@ -80,7 +77,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) { _, err = fStore.Create(ctx, folder.CreateFolderCommand{Title: "test2", UID: folder2UID, OrgID: 1, ParentUID: folder1UID, SignedInUser: tempUser}) require.NoError(t, err) - // create an alert rule inside of folder test2 ruleStore := ngalertstore.SetupStoreForTesting(t, db) _, err = ruleStore.InsertAlertRules(context.Background(), ngmodels.NewUserUID(tempUser), []ngmodels.AlertRule{ { @@ -108,7 +104,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) { }}) require.NoError(t, err) - // finally, create dashboard inside of test1 _, err = dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ Dashboard: simplejson.New(), FolderUID: folder1UID, @@ -116,7 +111,7 @@ func TestIntegrationDirectSQLStats(t *testing.T) { }) require.NoError(t, err) - store := &federated.LegacyStatsGetter{ + store := &LegacyStatsGetter{ SQL: legacysql.NewDatabaseProvider(db), } @@ -153,6 +148,105 @@ func TestIntegrationDirectSQLStats(t *testing.T) { ]`, string(jj)) }) + // New tests to verify per-resource fallback disabling + t.Run("GetStatsForFolder1_DisableDashboardsFallback", func(t *testing.T) { + ctx := context.Background() + ctx = request.WithNamespace(ctx, "default") + + store := &LegacyStatsGetter{ + SQL: legacysql.NewDatabaseProvider(db), + DisableSQLFallbackDashboards: true, + DisableSQLFallbackFolders: false, + } + + stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{ + Namespace: "default", + Folder: folder1UID, + }) + require.NoError(t, err) + + var hasDashboards, hasFolders bool + for _, s := range stats.Stats { + if s.Resource == "dashboards" { + hasDashboards = true + } + if s.Resource == "folders" { + hasFolders = true + require.EqualValues(t, 1, s.Count) + } + } + require.False(t, hasDashboards, "dashboards stats should be disabled") + require.True(t, hasFolders, "folders stats should be present") + }) + + t.Run("GetStatsForFolder1_DisableFoldersFallback", func(t *testing.T) { + ctx := context.Background() + ctx = request.WithNamespace(ctx, "default") + + store := &LegacyStatsGetter{ + SQL: legacysql.NewDatabaseProvider(db), + DisableSQLFallbackDashboards: false, + DisableSQLFallbackFolders: true, + } + + stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{ + Namespace: "default", + Folder: folder1UID, + }) + require.NoError(t, err) + + var hasDashboards, hasFolders bool + for _, s := range stats.Stats { + if s.Resource == "dashboards" { + hasDashboards = true + require.EqualValues(t, 1, s.Count) + } + if s.Resource == "folders" { + hasFolders = true + } + } + require.True(t, hasDashboards, "dashboards stats should be present") + require.False(t, hasFolders, "folders stats should be disabled") + }) + + t.Run("GetStatsForFolder1_DisableBothFallbacks", func(t *testing.T) { + ctx := context.Background() + ctx = request.WithNamespace(ctx, "default") + + store := &LegacyStatsGetter{ + SQL: legacysql.NewDatabaseProvider(db), + DisableSQLFallbackDashboards: true, + DisableSQLFallbackFolders: true, + } + + stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{ + Namespace: "default", + Folder: folder1UID, + }) + require.NoError(t, err) + + var hasDashboards, hasFolders bool + var hasAlertRules, hasLibrary bool + for _, s := range stats.Stats { + if s.Resource == "dashboards" { + hasDashboards = true + } + if s.Resource == "folders" { + hasFolders = true + } + if s.Resource == "alertrules" { + hasAlertRules = true + } + if s.Resource == "library_elements" { + hasLibrary = true + } + } + require.False(t, hasDashboards, "dashboards stats should be disabled") + require.False(t, hasFolders, "folders stats should be disabled") + require.True(t, hasAlertRules, "alertrules should still be present") + require.True(t, hasLibrary, "library_elements should still be present") + }) + t.Run("GetStatsForFolder2", func(t *testing.T) { ctx := context.Background() ctx = request.WithNamespace(ctx, "default") @@ -178,7 +272,7 @@ func TestIntegrationDirectSQLStats(t *testing.T) { "group": "sql-fallback", "resource": "folders" }, - { + { "group": "sql-fallback", "resource": "library_elements" } From c61a8a16fdc769725f6ea92ec332e81b5cf3e684 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Fri, 26 Sep 2025 15:47:30 +0200 Subject: [PATCH 23/31] Advisor: Retry before patching objects (#111665) --- apps/advisor/pkg/app/utils.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/advisor/pkg/app/utils.go b/apps/advisor/pkg/app/utils.go index 4ab55391bb1..d2810ec3644 100644 --- a/apps/advisor/pkg/app/utils.go +++ b/apps/advisor/pkg/app/utils.go @@ -14,6 +14,7 @@ import ( advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" "github.com/grafana/grafana/pkg/services/contexthandler" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) var retryAnnotationPollingInterval = 1 * time.Second @@ -81,7 +82,11 @@ func processCheck(ctx context.Context, log logging.Logger, client resource.Clien } return fmt.Errorf("error running steps: %w", err) } - + // Wait for the item to be persisted before patching the object + err = waitForItem(ctx, log, client, obj) + if err != nil { + return err + } report := &advisorv0alpha1.CheckReport{ Failures: failures, Count: int64(len(items)), @@ -264,6 +269,24 @@ func retryAnnotationChanged(oldObj, newObj resource.Object) bool { oldAnnotations[checks.RetryAnnotation] != newAnnotations[checks.RetryAnnotation] } +func waitForItem(ctx context.Context, log logging.Logger, client resource.Client, obj resource.Object) error { + _, err := client.Get(ctx, resource.Identifier{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }) + retries := 0 + for err != nil && k8serrors.IsNotFound(err) && retries < 5 { + log.Debug("Waiting for item to be persisted", "check", obj.GetName(), "retries", retries) + time.Sleep(retryAnnotationPollingInterval) + retries++ + _, err = client.Get(ctx, resource.Identifier{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }) + } + return err +} + // waitForRetryAnnotation waits for the retry annotation to match the item to retry func waitForRetryAnnotation(ctx context.Context, log logging.Logger, client resource.Client, obj resource.Object, itemToRetry string) error { currentObj, err := client.Get(ctx, resource.Identifier{ From 248b3239674f74a2bb2ea83316db87b38fce6327 Mon Sep 17 00:00:00 2001 From: Cory Forseth Date: Fri, 26 Sep 2025 08:50:05 -0500 Subject: [PATCH 24/31] Authz: add folder delete hook (#111649) * add delete hook * logging cleanup --- pkg/registry/apis/folders/hooks.go | 22 ++++++++++++++++++++-- pkg/registry/apis/folders/register.go | 1 + 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/registry/apis/folders/hooks.go b/pkg/registry/apis/folders/hooks.go index f0c954ecfeb..38942c19c5b 100644 --- a/pkg/registry/apis/folders/hooks.go +++ b/pkg/registry/apis/folders/hooks.go @@ -11,7 +11,10 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" ) -// "Almost nobody should use this hook" but we do because we need ctx and AfterCreate doesn't have it. +// K8S docs say "Almost nobody should use this hook" about the "begin" hooks, but we do because we only need to +// propagate if unistore write is successful. It also allows us to be a bit smarter about when to propagate, e.g. +// skipping root-level folders, skipping updates that don't change parent, etc. + func (b *FolderAPIBuilder) beginCreate(ctx context.Context, obj runtime.Object, _ *metav1.CreateOptions) (registry.FinishFunc, error) { log := logging.FromContext(ctx) meta, err := utils.MetaAccessor(obj) @@ -36,7 +39,6 @@ func (b *FolderAPIBuilder) beginCreate(ctx context.Context, obj runtime.Object, }, nil } -// "Almost nobody should use this hook" but we do because we need ctx and AfterUpdate doesn't have it. func (b *FolderAPIBuilder) beginUpdate(ctx context.Context, obj runtime.Object, old runtime.Object, _ *metav1.UpdateOptions) (registry.FinishFunc, error) { log := logging.FromContext(ctx) updatedMeta, err := utils.MetaAccessor(obj) @@ -66,6 +68,22 @@ func (b *FolderAPIBuilder) beginUpdate(ctx context.Context, obj runtime.Object, }, nil } +func (b *FolderAPIBuilder) afterDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + ctx := context.Background() + log := logging.DefaultLogger + meta, err := utils.MetaAccessor(obj) + if err != nil { + log.Error("Failed to access deleted folder object metadata", "error", err) + return + } + + log.Info("Propagating deleted folder to Zanzana", "folder", meta.GetName(), "parent", meta.GetFolder()) + err = b.permissionStore.DeleteFolderParents(ctx, meta.GetNamespace(), meta.GetName()) + if err != nil { + log.Warn("failed to propagate folder to zanzana", "err", err) + } +} + func (b *FolderAPIBuilder) writeFolderToZanzana(ctx context.Context, folder utils.GrafanaMetaAccessor) { err := b.permissionStore.SetFolderParent(ctx, folder.GetNamespace(), folder.GetName(), folder.GetFolder()) if err != nil { diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index bce852eb66b..12ac60b46de 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -200,6 +200,7 @@ func (b *FolderAPIBuilder) registerPermissionHooks(store *genericregistry.Store) log.Info("Enabling Zanzana folder propagation hooks") store.BeginCreate = b.beginCreate store.BeginUpdate = b.beginUpdate + store.AfterDelete = b.afterDelete } else { log.Info("Zanzana is not enabled; skipping folder propagation hooks") } From 00fb2e9537aa80ad364375406dcac4f22fa917c5 Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Fri, 26 Sep 2025 09:20:27 -0500 Subject: [PATCH 25/31] SCIM: update docs to mention SCIM UI (#110906) * SCIM: update docs to mention SCIM UI * simplify * fix typo * expand UI section, add UI references to IdP docs * fix vale warning * Update docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md Co-authored-by: linoman <2051016+linoman@users.noreply.github.com> --------- Co-authored-by: linoman <2051016+linoman@users.noreply.github.com> --- .../configure-scim-provisioning/_index.md | 35 ++++++++++++++++++- .../configure-scim-with-azuread/_index.md | 23 ++++++++++-- .../configure-scim-with-okta/_index.md | 23 ++++++++++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md index 7d7c1fda87d..3298ec40bde 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md @@ -71,7 +71,40 @@ When you enable SCIM in Grafana, the following requirements and restrictions app - Configure `userUID` SAML assertion in [Azure AD](/docs/grafana//setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/#configure-saml-assertions-when-using-scim-provisioning) - Configure `userUID` SAML assertion in [Okta](/docs/grafana//setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-okta/#configure-saml-assertions-when-using-scim-provisioning) -## Configure SCIM in Grafana +## Configure SCIM using the Grafana user interface + +You can configure SCIM in Grafana using the Grafana user interface. To do this, navigate to **Administration > Authentication > SCIM**. + +The Grafana SCIM UI provides the following advantages over configuring SCIM in the Grafana configuration file: + +- It is accessible by Grafana Cloud users +- It doesn't require Grafana to be restarted after a configuration update +- Using the authentication settings permission allows us to restrict Grafana’s access scope rather than relying on an overly permissive role such as Admin. + +{{< admonition type="note" >}} +Any configuration changes made through the Grafana user interface (UI) will take precedence over settings specified in the Grafana configuration file or through environment variables. This means that if you modify any configuration settings in the UI, they will override any corresponding settings set via environment variables or defined in the configuration file. +{{< /admonition >}} + +### Configure SCIM settings + +Sign in to Grafana and navigate to **Administration > Authentication > SCIM**. Here you can configure the following settings: + +| Setting | Required | Description | Default | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `Enable Group Sync` | No | Enable SCIM group provisioning. When enabled, Grafana will create, update, and delete teams based on SCIM requests from your identity provider. Cannot be enabled if Team Sync is enabled. | `false` | +| `Reject Non-Provisioned Users` | No | When enabled, prevents non-SCIM provisioned users from signing in. Cloud Portal users can always sign in regardless of this setting. | `false` | +| `Enable User Sync` | Yes | Enable SCIM user provisioning. When enabled, Grafana will create, update, and deactivate users based on SCIM requests from your identity provider. | `false` | + +The SCIM UI also displays information that may help you configure SCIM in your identity provider, including stack domain, stack ID, and tenant URL. + +### Next steps + +After configuring SCIM in Grafana, configure your identity provider: + +- [Configure SCIM with Okta](configure-scim-with-okta/) +- [Configure SCIM with Azure AD](configure-scim-with-azuread/) + +## Configure SCIM using the configuration file The table below describes all SCIM configuration options. Like any other Grafana configuration, you can apply these options as [environment variables](/docs/grafana//setup-grafana/configure-grafana/#override-configuration-with-environment-variables). diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md index bc04f80fa52..efdf8ae3c56 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md @@ -61,8 +61,22 @@ To enable SCIM provisioning in Grafana, create a service account and generate a 1. Navigate to **Administration > Users and access > Service accounts** 2. Click **Add service account** -3. Create a new service account with Admin role -4. Create a new token for the newly created service account and save it securely +3. Create a new service account with **Role: "None"** +4. In the service account **Permissions** tab, add these permissions: + + **Allow the service account to sync users:** + - `org.users:read` + - `org.users:write` + - `org.users:add` + - `org.users:remove` + + **Allow the service account to sync groups:** + - `teams:read` + - `teams:create` + - `teams:write` + - `teams:delete` + +5. Create a new token for the newly created service account and save it securely - This token will be used in the Azure AD configuration ## Configure SCIM in Azure AD @@ -84,6 +98,10 @@ Configure the enterprise application in Azure AD to enable automated user and te 3. Configure the following settings: - **Tenant URL:** + + You can copy the tenant URL directly from the SCIM UI at **Administration > Authentication > SCIM**. Your stack domain and stack ID can also be found in the SCIM UI. + + Alternatively, you can construct the URL manually: - For Grafana Cloud instances: ``` https://{stack-name}.grafana.net/apis/scim.grafana.app/v0alpha1/namespaces/stacks-{stack-id} @@ -94,6 +112,7 @@ Configure the enterprise application in Azure AD to enable automated user and te https://{your-grafana-domain}/apis/scim.grafana.app/v0alpha1/namespaces/default ``` Replace `{your-grafana-domain}` with your Grafana instance's domain (e.g., `grafana.yourcompany.com`). + - **Secret Token:** Enter the service account token from Grafana 4. Click **Test connection** to verify the configuration diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md index 50434391f7e..b4642638b08 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md @@ -60,8 +60,22 @@ To enable SCIM provisioning in Grafana, create a service account and generate an 1. Navigate to **Administration > Users and access > Service accounts** 2. Click **Add service account** -3. Create a new service account with Admin role -4. Create a new token for the newly created service account and save it securely +3. Create a new service account with **Role: "None"** +4. In the service account **Permissions** tab, add these permissions: + + **Allow the service account to sync users:** + - `org.users:read` + - `org.users:write` + - `org.users:add` + - `org.users:remove` + + **Allow the service account to sync groups:** + - `teams:read` + - `teams:create` + - `teams:write` + - `teams:delete` + +5. Create a new token for the newly created service account and save it securely - This token will be used in the Okta configuration ## Configure SCIM in Okta @@ -83,6 +97,10 @@ To enable user provisioning through SCIM, configure the SCIM integration setting In the **Integration** tab, configure: - **SCIM Connector base URL:** + + You can copy the complete SCIM Connector base URL directly from the SCIM UI at **Administration > Authentication > SCIM**. This is displayed as the Tenant URL in the UI. Your stack domain and stack ID can also be found in the SCIM UI. + + Alternatively, you can construct the URL manually: - For Grafana Cloud instances: ``` https://{stack-name}.grafana.net/apis/scim.grafana.app/v0alpha1/namespaces/stacks-{stack-id} @@ -93,6 +111,7 @@ In the **Integration** tab, configure: https://{your-grafana-domain}/apis/scim.grafana.app/v0alpha1/namespaces/default ``` Replace `{your-grafana-domain}` with your Grafana instance's domain (e.g., `grafana.yourcompany.com`). + - **Unique identifier field:** userName - **Supported provisioning actions:** - Import New Users and Profile Updates From e7f57446c14ff2f7495ad6f14e99d30d786448c9 Mon Sep 17 00:00:00 2001 From: Taras <9948629+taraspos@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:23:40 +0100 Subject: [PATCH 26/31] PostgreSQL: Support PGPASSFILE by making password optional (#108856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * datasource(postgresql): add support of pgpass file * remove `required` label for password field * set `runPostgresTests` back to false * fix after merge conflict * add pgx_test * set `runPostgresTests` back to `false` * Add `no password` test case to the `pgx_test.go` as well * fix `postgres_pgx_test.go` * Update datasource docs * docs wording * docs: `datasource` -> `data source` * Update docs/sources/datasources/postgres/configure/_index.md Co-authored-by: Zoltán Bedi * run prettier - docs --------- Co-authored-by: Zoltán Bedi --- .../datasources/postgres/configure/_index.md | 16 ++--- .../grafana-postgresql-datasource/postgres.go | 9 ++- .../postgres_pgx_test.go | 44 +++++++++--- .../postgres_test.go | 70 ++++++++++++++++--- .../configuration/ConfigurationEditor.tsx | 2 +- 5 files changed, 109 insertions(+), 32 deletions(-) diff --git a/docs/sources/datasources/postgres/configure/_index.md b/docs/sources/datasources/postgres/configure/_index.md index ed229013e9f..f30a1c3ccc1 100644 --- a/docs/sources/datasources/postgres/configure/_index.md +++ b/docs/sources/datasources/postgres/configure/_index.md @@ -102,14 +102,14 @@ Following is a list of PostgreSQL configuration options: **Authentication section:** -| Setting | Description | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Username | Enter the username used to connect to your PostgreSQL database. | -| Password | Enter the password used to connect to the PostgreSQL database. | -| TLS/SSL Mode | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When TLS/SSL Mode is disabled, TLS/SSL Method and TLS/SSL Auth Details aren’t visible options. | -| TLS/SSL Method | Determines how TLS/SSL certificates are configured. | -| - File system path | This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. | -| - Certificate content | This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. | +| Setting | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Username | Enter the username used to connect to your PostgreSQL database. | +| Password | Enter the password used to connect to the PostgreSQL database. If a password is not specified and your PostgreSQL is configured to request a password, data source will look for a [standard PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html). | +| TLS/SSL Mode | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When TLS/SSL Mode is disabled, TLS/SSL Method and TLS/SSL Auth Details aren’t visible options. | +| TLS/SSL Method | Determines how TLS/SSL certificates are configured. | +| - File system path | This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. | +| - Certificate content | This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. | **TLS/SSL Auth Details:** diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index a2ef32bbd76..a889ef662a7 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -329,8 +329,13 @@ func parseNetworkAddress(url string, logger log.Logger) (string, int, error) { } func buildBaseConnectionString(params connectionParams) string { - connStr := fmt.Sprintf("user='%s' password='%s' host='%s' dbname='%s'", - escape(params.user), escape(params.password), escape(params.host), escape(params.database)) + connStr := fmt.Sprintf("user='%s' host='%s' dbname='%s'", + escape(params.user), escape(params.host), escape(params.database)) + + if params.password != "" { + connStr += fmt.Sprintf(" password='%s'", escape(params.password)) + } + if params.port > 0 { connStr += fmt.Sprintf(" port=%d", params.port) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go index bb1494717ed..6e14282d824 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math/rand" + "os" "strings" "testing" "time" @@ -41,7 +42,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='/var/run/postgresql' dbname='database' password='password' sslmode='verify-full'", }, { desc: "TCP host", @@ -50,7 +51,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'", }, { desc: "verify-ca automatically adds disable-sni", @@ -59,7 +60,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-ca"}, - expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0", + expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0", }, { desc: "TCP/port host", @@ -68,7 +69,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'", + expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-full'", }, { desc: "Ipv6 host", @@ -77,7 +78,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='::1' dbname='database' password='password' sslmode='verify-full'", }, { desc: "Ipv6/port host", @@ -86,7 +87,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'", + expConnStr: "user='user' host='::1' dbname='database' password='password' port=1234 sslmode='verify-full'", }, { desc: "Invalid port", @@ -103,7 +104,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: `p'\assword`, database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`, + expConnStr: `user='user' host='host' dbname='database' password='p\'\\assword' sslmode='verify-full'`, }, { desc: "User/DB with single quote and backslash", @@ -112,7 +113,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: `password`, database: `d'\atabase`, tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`, + expConnStr: `user='u\'\\ser' host='host' dbname='d\'\\atabase' password='password' sslmode='verify-full'`, }, { desc: "Custom TLS mode disabled", @@ -121,7 +122,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "disable"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'", + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='disable'", }, { desc: "Custom TLS mode verify-full with certificate files", @@ -135,9 +136,18 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) { CertFile: "i/am/coding/client.crt", CertKeyFile: "i/am/coding/client.key", }, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " + + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full' " + "sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'", }, + { + desc: "No password", + host: "host", + user: "user", + password: "", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' host='host' dbname='database' sslmode='verify-full'", + }, } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { @@ -1555,4 +1565,18 @@ func TestIntegrationPostgresPGX(t *testing.T) { require.Equal(t, "updated", *nameValue) }) }) + + t.Run("Test Postgres connection with pgpass file", func(t *testing.T) { + require.NoError(t, preparePgpassFile(t)) + require.FileExists(t, os.Getenv("PGPASSFILE"), "Make sure that PGPASSFILE is set and file exists") + + cnnstr := postgresTestDBConnString() + require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string") + + pgpassPool, _, err := newPostgresPGX(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + require.NoError(t, err) + + _, err = pgpassPool.Query(t.Context(), "SELECT 1") // Test connection + require.NoError(t, err) + }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go index 37286ffcad6..b71bd23b387 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_test.go @@ -5,6 +5,7 @@ import ( "fmt" "math/rand" "os" + "path/filepath" "strings" "testing" "time" @@ -46,7 +47,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='/var/run/postgresql' dbname='database' password='password' sslmode='verify-full'", }, { desc: "TCP host", @@ -55,7 +56,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'", }, { desc: "verify-ca automatically adds disable-sni", @@ -64,7 +65,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-ca"}, - expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0", + expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0", }, { desc: "TCP/port host", @@ -73,7 +74,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'", + expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-full'", }, { desc: "Ipv6 host", @@ -82,7 +83,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' host='::1' dbname='database' password='password' sslmode='verify-full'", }, { desc: "Ipv6/port host", @@ -91,7 +92,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'", + expConnStr: "user='user' host='::1' dbname='database' password='password' port=1234 sslmode='verify-full'", }, { desc: "Invalid port", @@ -108,7 +109,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: `p'\assword`, database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`, + expConnStr: `user='user' host='host' dbname='database' password='p\'\\assword' sslmode='verify-full'`, }, { desc: "User/DB with single quote and backslash", @@ -117,7 +118,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: `password`, database: `d'\atabase`, tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`, + expConnStr: `user='u\'\\ser' host='host' dbname='d\'\\atabase' password='password' sslmode='verify-full'`, }, { desc: "Custom TLS mode disabled", @@ -126,7 +127,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "disable"}, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'", + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='disable'", }, { desc: "Custom TLS mode verify-full with certificate files", @@ -140,9 +141,18 @@ func TestIntegrationGenerateConnectionString(t *testing.T) { CertFile: "i/am/coding/client.crt", CertKeyFile: "i/am/coding/client.key", }, - expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " + + expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full' " + "sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'", }, + { + desc: "No password", + host: "host", + user: "user", + password: "", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' host='host' dbname='database' sslmode='verify-full'", + }, } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { @@ -1370,6 +1380,20 @@ func TestIntegrationPostgres(t *testing.T) { require.Empty(t, frames[0].Fields) }) }) + + t.Run("Test Postgres connection with pgpass file", func(t *testing.T) { + require.NoError(t, preparePgpassFile(t)) + require.FileExists(t, os.Getenv("PGPASSFILE"), "Make sure that PGPASSFILE is set and file exists") + + cnnstr := postgresTestDBConnString() + require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string") + + dbPgpass, _, err := newPostgres(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + require.NoError(t, err) + + _, err = dbPgpass.Exec("SELECT 1") // Test connection + require.NoError(t, err) + }) } func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { @@ -1401,6 +1425,23 @@ func isTestDbPostgres() bool { return false } +func preparePgpassFile(t *testing.T) error { + dir := t.TempDir() + t.Setenv("PGPASSFILE", filepath.Join(dir, ".pgpass")) + + host := os.Getenv("POSTGRES_HOST") + if host == "" { + host = "localhost" + } + port := os.Getenv("POSTGRES_PORT") + if port == "" { + port = "5432" + } + + return os.WriteFile(filepath.Join(dir, ".pgpass"), + []byte(fmt.Sprintf("%s:%s:grafanadstest:grafanatest:grafanatest", host, port)), 0600) +} + func postgresTestDBConnString() string { host := os.Getenv("POSTGRES_HOST") if host == "" { @@ -1410,6 +1451,13 @@ func postgresTestDBConnString() string { if port == "" { port = "5432" } - return fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable", + + connStr := fmt.Sprintf("user=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable", host, port) + + if os.Getenv("PGPASSFILE") == "" { + connStr += " password=grafanatest" + } + + return connStr } diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx index 273c60df162..8a76ff319f4 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx @@ -159,7 +159,7 @@ export const PostgresConfigEditor = (props: DataSourcePluginOptionsEditorProps

- + Date: Fri, 26 Sep 2025 17:12:44 +0100 Subject: [PATCH 27/31] Plugins: Add toggle for prioritizing loading plugins from CDN (#111671) add toggle for loading cdn plugins first --- .../src/types/featureToggles.gen.ts | 5 ++++ pkg/services/featuremgmt/registry.go | 8 ++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 +++ pkg/services/featuremgmt/toggles_gen.json | 27 +++++++++++++++++++ 5 files changed, 45 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 58cf53cfb2d..2072899e949 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1188,4 +1188,9 @@ export interface FeatureToggles { * @default false */ filterOutBotsFromFrontendLogs?: boolean; + /** + * Prioritize loading plugins from the CDN before other sources + * @default false + */ + cdnPluginsLoadFirst?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 15bf3d45d39..5d45f5394bf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2061,6 +2061,14 @@ var ( Owner: grafanaPluginsPlatformSquad, Expression: "false", }, + { + Name: "cdnPluginsLoadFirst", + Description: "Prioritize loading plugins from the CDN before other sources", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaPluginsPlatformSquad, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a1f04785edc..8eb68007bcb 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -264,3 +264,4 @@ dskitBackgroundServices,experimental,@grafana/plugins-platform-backend,false,tru pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false filterOutBotsFromFrontendLogs,experimental,@grafana/plugins-platform-backend,false,false,true +cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4cddb8fe39a..931857dcdeb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1066,4 +1066,8 @@ const ( // FlagFilterOutBotsFromFrontendLogs // Filter out bots from collecting data for Frontend Observability FlagFilterOutBotsFromFrontendLogs = "filterOutBotsFromFrontendLogs" + + // FlagCdnPluginsLoadFirst + // Prioritize loading plugins from the CDN before other sources + FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index cbc007c0902..dc1b73b97b0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -836,6 +836,33 @@ "frontend": true } }, + { + "metadata": { + "name": "cdnPluginsLoadFirst", + "resourceVersion": "1758882341746", + "creationTimestamp": "2025-09-26T10:25:41Z" + }, + "spec": { + "description": "Prioritize loading plugins from the CDN before other sources", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "expression": "false" + } + }, + { + "metadata": { + "name": "cdnPluginsLoadedFirst", + "resourceVersion": "1758881920003", + "creationTimestamp": "2025-09-26T10:18:40Z", + "deletionTimestamp": "2025-09-26T10:25:41Z" + }, + "spec": { + "description": "Prioritize loading plugins from the CDN before other sources", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "expression": "false" + } + }, { "metadata": { "name": "cloudRBACRoles", From e7ca0fdf7bc9a0370c2646b1f7279cf0949594f2 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Fri, 26 Sep 2025 17:55:00 +0100 Subject: [PATCH 28/31] LBAC for datasources: Adds cleanup of lbac rules added for teams that have been deleted (#111405) add cleanup of lbac rules for teams that have been deleted --- pkg/server/wire_gen.go | 4 +- pkg/services/cleanup/cleanup.go | 139 +++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 61b6607a162..9d7778c8f81 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -600,7 +600,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api deleteExpiredService := image.ProvideDeleteExpiredService(dBstore) tempuserService := tempuserimpl.ProvideService(sqlStore, cfg) cleanupServiceImpl := annotationsimpl.ProvideCleanupService(sqlStore, cfg) - cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService) secretsKVStore, err := kvstore2.ProvideService(sqlStore, secretsService) if err != nil { return nil, err @@ -612,6 +611,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } + cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService, teamService, service15) correlationsService, err := correlations.ProvideService(sqlStore, routeRegisterImpl, service15, accessControl, inProcBus, quotaService, cfg) if err != nil { return nil, err @@ -1200,7 +1200,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac deleteExpiredService := image.ProvideDeleteExpiredService(dBstore) tempuserService := tempuserimpl.ProvideService(sqlStore, cfg) cleanupServiceImpl := annotationsimpl.ProvideCleanupService(sqlStore, cfg) - cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService) secretsKVStore, err := kvstore2.ProvideService(sqlStore, secretsService) if err != nil { return nil, err @@ -1212,6 +1211,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } + cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService, teamService, service15) correlationsService, err := correlations.ProvideService(sqlStore, routeRegisterImpl, service15, accessControl, inProcBus, quotaService, cfg) if err != nil { return nil, err diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 7730ee1dcf4..a4fce5a12de 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -28,11 +28,13 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/image" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/shorturls" + "github.com/grafana/grafana/pkg/services/team" tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/setting" ) @@ -58,12 +60,14 @@ type CleanUpService struct { alertRuleService AlertRuleService clientConfigProvider grafanaapiserver.RestConfigProvider orgService org.Service + teamService team.Service + dataSourceService datasources.DataSourceService } func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serverLockService *serverlock.ServerLockService, shortURLService shorturls.Service, sqlstore db.DB, queryHistoryService queryhistory.Service, dashboardVersionService dashver.Service, dashSnapSvc dashboardsnapshots.Service, deleteExpiredImageService *image.DeleteExpiredService, - tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner, service AlertRuleService, clientConfigProvider grafanaapiserver.RestConfigProvider, orgService org.Service) *CleanUpService { + tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner, service AlertRuleService, clientConfigProvider grafanaapiserver.RestConfigProvider, orgService org.Service, teamService team.Service, dataSourceService datasources.DataSourceService) *CleanUpService { s := &CleanUpService{ Cfg: cfg, Features: Features, @@ -81,6 +85,8 @@ func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serve alertRuleService: service, clientConfigProvider: clientConfigProvider, orgService: orgService, + teamService: teamService, + dataSourceService: dataSourceService, } return s } @@ -125,6 +131,7 @@ func (srv *CleanUpService) clean(ctx context.Context) { {"expire old user invites", srv.expireOldUserInvites}, {"delete stale query history", srv.deleteStaleQueryHistory}, {"expire old email verifications", srv.expireOldVerifications}, + {"cleanup stale LBAC rules", srv.cleanupStaleLBACRules}, } if srv.Cfg.ShortLinkExpiration > 0 { @@ -418,3 +425,133 @@ func (srv *CleanUpService) cleanUpTrashAlertRules(ctx context.Context) { logger.Debug("Cleaned up deleted alert rules", "rows affected", affected) } } + +// cleanupStaleLBACRules exists to clean up lbac rules that are stale from teams getting deleted as we do not have +// cascading deletions on teams to delete existing lbac rules +func (srv *CleanUpService) cleanupStaleLBACRules(ctx context.Context) { + logger := srv.log.FromContext(ctx) + + // Get all datasources + allDataSources, err := srv.dataSourceService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{}) + if err != nil { + logger.Error("Failed to get datasources for LBAC cleanup", "error", err) + return + } + + var totalCleaned int + var totalDataSources int + + for _, ds := range allDataSources { + if ds.JsonData == nil { + continue + } + + // Check if datasource has team LBAC rules + teamHTTPHeaders, err := datasources.GetTeamHTTPHeaders(ds.JsonData) + if err != nil || teamHTTPHeaders == nil { + continue + } + + totalDataSources++ + + // Extract team UIDs and check if teams still exist + cleanedRules, removedCount := srv.getLBACRulesForTeamsStillExisting(ctx, teamHTTPHeaders, ds.OrgID) + + if removedCount > 0 { + // Update the datasource with cleaned rules + err := srv.updateDataSourceLBACRules(ctx, ds, cleanedRules) + if err != nil { + logger.Error("Failed to update datasource LBAC rules", + "datasource", ds.UID, "error", err) + } else { + totalCleaned += removedCount + logger.Debug("Cleaned stale LBAC rules", + "datasource", ds.UID, "removed", removedCount) + } + } + } + + if totalCleaned > 0 { + logger.Info("Cleaned up stale team LBAC rules", + "datasources_processed", totalDataSources, + "total_rules_removed", totalCleaned) + } +} + +func (srv *CleanUpService) getLBACRulesForTeamsStillExisting(ctx context.Context, teamHeaders *datasources.TeamHTTPHeaders, orgID int64) (*datasources.TeamHTTPHeaders, int) { + logger := srv.log.FromContext(ctx) + cleanedHeaders := &datasources.TeamHTTPHeaders{Headers: make(map[string][]datasources.TeamHTTPHeader)} + removedCount := 0 + + for teamIdentifier, headers := range teamHeaders.Headers { + // Determine if this is a UID or ID + var teamUID string + teamID, err := strconv.ParseInt(teamIdentifier, 10, 64) + + if err != nil { + // It's a UID + teamUID = teamIdentifier + } else { + // It's an ID, need to resolve to UID + teamByID, err := srv.teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ + OrgID: orgID, + ID: teamID, + }) + if err != nil { + logger.Debug("Team ID no longer exists, removing LBAC rules", + "teamID", teamIdentifier, "orgID", orgID) + removedCount++ + continue + } + teamUID = teamByID.UID + } + + // Check if team still exists by UID + _, err = srv.teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ + OrgID: orgID, + UID: teamUID, + }) + + if err != nil { + logger.Debug("Team UID no longer exists, removing LBAC rules", + "teamUID", teamUID, "orgID", orgID) + removedCount++ + continue + } + + // Team exists, keep the rules + cleanedHeaders.Headers[teamIdentifier] = headers + } + + return cleanedHeaders, removedCount +} + +func (srv *CleanUpService) updateDataSourceLBACRules(ctx context.Context, ds *datasources.DataSource, cleanedHeaders *datasources.TeamHTTPHeaders) error { + // Update JsonData with cleaned rules + jsonData := ds.JsonData + jsonData.Set("teamHttpHeaders", cleanedHeaders) + + updateCmd := &datasources.UpdateDataSourceCommand{ + ID: ds.ID, + OrgID: ds.OrgID, + UID: ds.UID, + Name: ds.Name, + Type: ds.Type, + Access: ds.Access, + URL: ds.URL, + User: ds.User, + Database: ds.Database, + BasicAuth: ds.BasicAuth, + BasicAuthUser: ds.BasicAuthUser, + WithCredentials: ds.WithCredentials, + IsDefault: ds.IsDefault, + JsonData: jsonData, + AllowLBACRuleUpdates: true, + Version: ds.Version, + ReadOnly: ds.ReadOnly, + APIVersion: ds.APIVersion, + } + + _, err := srv.dataSourceService.UpdateDataSource(ctx, updateCmd) + return err +} From 004f30fcb7b8bbb68130967bd41b084058cb7cc1 Mon Sep 17 00:00:00 2001 From: John Troy Date: Fri, 26 Sep 2025 13:33:29 -0400 Subject: [PATCH 29/31] Docs: Fix broken links in SAML docs (#111039) * Rename this heading to match the link in 'Request Initiation' * Fix link to 'Configure SAML using the Grafana configuration file' and make the link text match --- .../configure-authentication/saml/_index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/_index.md index c5785c107a6..923a3094aa1 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/_index.md @@ -29,7 +29,7 @@ SAML authentication integration allows your Grafana users to log in by using an You can configure SAML authentication in Grafana through one of the following methods: -- Configure SAML using the [Grafana configuration file](#configure-saml-using-the-grafana-config-file) +- Configure SAML using the [Grafana configuration file](#configure-saml-using-the-grafana-configuration-file) - Configure SAML using the [SSO Settings API](https://grafana.com/docs/grafana//developers/http_api/sso-settings/) - Configure SAML using the [SAML user interface](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/saml/saml-ui/) - Configure SAML using the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana//docs/resources/sso_settings) @@ -238,7 +238,7 @@ To allow Grafana to initiate a POST request to the IdP, update the `content_secu For Grafana Cloud instances, please contact Grafana Support to update the `content_security_policy_template` and `content_security_policy_report_only_template` settings of your Grafana instance. Please provide the metadata URL/file of your IdP. {{< /admonition >}} -## IdP-initiated login +## IdP-initiated Single Sign-On (SSO) By default, Grafana allows only service provider (SP) initiated logins (when the user logs in with SAML via the login page in Grafana). If you want users to log in into Grafana directly from your identity provider (IdP), set the `allow_idp_initiated` configuration option to `true` and configure `relay_state` with the same value specified in the IdP configuration. From 052b6e3dfd5ffcb447fbfbff02d364ad1630e162 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Fri, 26 Sep 2025 19:08:56 +0100 Subject: [PATCH 30/31] LBAC rules: Change to query all teams before looping for better performance (#111689) performance nit for querying all teams existance before looping through lbac rules --- pkg/services/cleanup/cleanup.go | 50 ++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index a4fce5a12de..628df57d8c9 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -483,40 +483,46 @@ func (srv *CleanUpService) getLBACRulesForTeamsStillExisting(ctx context.Context cleanedHeaders := &datasources.TeamHTTPHeaders{Headers: make(map[string][]datasources.TeamHTTPHeader)} removedCount := 0 + allTeams, err := srv.teamService.SearchTeams(ctx, &team.SearchTeamsQuery{ + OrgID: orgID, + }) + if err != nil { + logger.Error("Failed to get teams for LBAC cleanup", "error", err) + return nil, removedCount + } + + teamUIDs := make(map[string]bool) + for _, team := range allTeams.Teams { + teamUIDs[team.UID] = true + } + teamIDs := make(map[int64]bool) + for _, team := range allTeams.Teams { + teamIDs[team.ID] = true + } + for teamIdentifier, headers := range teamHeaders.Headers { // Determine if this is a UID or ID - var teamUID string teamID, err := strconv.ParseInt(teamIdentifier, 10, 64) if err != nil { // It's a UID - teamUID = teamIdentifier + if _, ok := teamUIDs[teamIdentifier]; !ok { + logger.Debug("Team UID no longer exists, removing LBAC rules", + "teamUID", teamIdentifier, "orgID", orgID) + removedCount++ + continue + } } else { - // It's an ID, need to resolve to UID - teamByID, err := srv.teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ - OrgID: orgID, - ID: teamID, - }) - if err != nil { + if _, ok := teamIDs[teamID]; !ok { logger.Debug("Team ID no longer exists, removing LBAC rules", "teamID", teamIdentifier, "orgID", orgID) removedCount++ continue } - teamUID = teamByID.UID - } - - // Check if team still exists by UID - _, err = srv.teamService.GetTeamByID(ctx, &team.GetTeamByIDQuery{ - OrgID: orgID, - UID: teamUID, - }) - - if err != nil { - logger.Debug("Team UID no longer exists, removing LBAC rules", - "teamUID", teamUID, "orgID", orgID) - removedCount++ - continue + // team exists in lbac and exists in teams + // lbac rule has team.ID and team exists + // update the rule with the UID instead + // TODO: we could replace the ID for the UID here we want } // Team exists, keep the rules From a333e8a8dae2f9741cf1a35178459099628080c6 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:16:06 -0400 Subject: [PATCH 31/31] Background Services: Remove dskitBackgroundServices toggle (#111255) --- .../src/types/featureToggles.gen.ts | 5 - pkg/modules/modules.go | 1 + .../backgroundsvcs/adapter/manager.go | 35 ++++-- .../backgroundsvcs/adapter/manager_test.go | 87 +++++-------- pkg/server/server.go | 117 +++--------------- pkg/server/server_test.go | 50 ++++---- pkg/services/featuremgmt/registry.go | 10 -- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 1 + 10 files changed, 96 insertions(+), 215 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2072899e949..d5730d2dc71 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1169,11 +1169,6 @@ export interface FeatureToggles { */ prometheusTypeMigration?: boolean; /** - * Enables dskit background service wrapper - * @default false - */ - dskitBackgroundServices?: boolean; - /** * Enables running plugins in containers * @default false */ diff --git a/pkg/modules/modules.go b/pkg/modules/modules.go index 286f8e5e350..1556ec77f06 100644 --- a/pkg/modules/modules.go +++ b/pkg/modules/modules.go @@ -23,6 +23,7 @@ type Registry interface { } type Manager interface { + services.NamedService Registry Engine } diff --git a/pkg/registry/backgroundsvcs/adapter/manager.go b/pkg/registry/backgroundsvcs/adapter/manager.go index d43035f3d62..edd84418bd8 100644 --- a/pkg/registry/backgroundsvcs/adapter/manager.go +++ b/pkg/registry/backgroundsvcs/adapter/manager.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/dskit/services" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -16,7 +17,7 @@ var ( stopTimeout = 30 * time.Second ) -type managerAdapter struct { +type ManagerAdapter struct { services.NamedService reg registry.BackgroundServiceRegistry @@ -32,8 +33,8 @@ type managerAdapter struct { // - Graceful shutdown with proper cleanup ordering // // Services implementing CanBeDisabled that are disabled will be skipped. -func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *managerAdapter { - m := &managerAdapter{ +func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *ManagerAdapter { + m := &ManagerAdapter{ reg: reg, dependencyMap: dependencyMap(), } @@ -41,7 +42,12 @@ func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *managerAdapter { return m } -func (m *managerAdapter) starting(ctx context.Context) error { +func (m *ManagerAdapter) WithDependencies(dependencyMap map[string][]string) *ManagerAdapter { + m.dependencyMap = dependencyMap + return m +} + +func (m *ManagerAdapter) starting(ctx context.Context) error { spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.starting") defer span.End() logger := log.New("backgroundsvcs.managerAdapter").FromContext(spanCtx) @@ -76,16 +82,20 @@ func (m *managerAdapter) starting(ctx context.Context) error { manager.RegisterModule(BackgroundServices, nil) m.manager = manager - return nil + if err := m.manager.StartAsync(ctx); err != nil { + return err + } + return m.manager.AwaitRunning(ctx) } -func (m *managerAdapter) running(ctx context.Context) error { - spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.running") +func (m *ManagerAdapter) running(ctx context.Context) error { + newCtx := trace.ContextWithSpan(context.Background(), trace.SpanFromContext(ctx)) + spanCtx, span := tracing.Start(newCtx, "backgroundsvcs.managerAdapter.running") defer span.End() - return m.manager.Run(spanCtx) + return m.manager.AwaitTerminated(spanCtx) } -func (m *managerAdapter) stopping(failure error) error { +func (m *ManagerAdapter) stopping(failure error) error { ctx, cancel := context.WithTimeout(context.Background(), stopTimeout) defer cancel() spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.stopping") @@ -98,15 +108,16 @@ func (m *managerAdapter) stopping(failure error) error { } // Run initializes and starts all background services using dskit's module and service patterns. -func (m *managerAdapter) Run(ctx context.Context) error { +func (m *ManagerAdapter) Run(ctx context.Context) error { if err := m.StartAsync(ctx); err != nil { return err } - return m.AwaitTerminated(ctx) + stopCtx := trace.ContextWithSpan(context.Background(), trace.SpanFromContext(ctx)) + return m.AwaitTerminated(stopCtx) } // Shutdown calls calls the underlying manager's Shutdown -func (m *managerAdapter) Shutdown(ctx context.Context, reason string) error { +func (m *ManagerAdapter) Shutdown(ctx context.Context, reason string) error { m.StopAsync() return m.AwaitTerminated(ctx) } diff --git a/pkg/registry/backgroundsvcs/adapter/manager_test.go b/pkg/registry/backgroundsvcs/adapter/manager_test.go index d511b546cbb..b0c627b05b0 100644 --- a/pkg/registry/backgroundsvcs/adapter/manager_test.go +++ b/pkg/registry/backgroundsvcs/adapter/manager_test.go @@ -28,10 +28,10 @@ func TestNewManagerAdapter(t *testing.T) { func TestManagerAdapter_Starting(t *testing.T) { t.Run("empty registry initializes manager", func(t *testing.T) { reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{}} - adapter := NewManagerAdapter(reg) - adapter.dependencyMap = map[string][]string{ - BackgroundServices: {}, - } + adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{ + BackgroundServices: {Core}, + Core: {}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -55,10 +55,10 @@ func TestManagerAdapter_Starting(t *testing.T) { reg := &mockBackgroundServiceRegistry{ services: []registry.BackgroundService{enabledSvc, disabledSvc, namedSvc}, } - adapter := NewManagerAdapter(reg) - adapter.dependencyMap = map[string][]string{ - BackgroundServices: {}, - } + adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{ + BackgroundServices: {Core}, + Core: {}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -110,8 +110,11 @@ func TestManagerAdapter_Starting(t *testing.T) { // Pre-populate the dependency map with the service using the actual service name that will be used serviceName := "*adapter.mockNamedService" - adapter.dependencyMap[serviceName] = []string{"custom-dependency"} - initialBgDeps := append([]string{}, adapter.dependencyMap[BackgroundServices]...) + adapter.WithDependencies(map[string][]string{ + serviceName: {BackgroundServices}, + Core: {}, + BackgroundServices: {Core}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -121,18 +124,20 @@ func TestManagerAdapter_Starting(t *testing.T) { require.NotNil(t, adapter.manager) // Verify the existing dependency was not overwritten - require.Equal(t, []string{"custom-dependency"}, adapter.dependencyMap[serviceName]) + require.Equal(t, []string{BackgroundServices}, adapter.dependencyMap[serviceName]) // Verify BackgroundServices dependencies were not modified (should not contain the service twice) finalBgDeps := adapter.dependencyMap[BackgroundServices] - require.Equal(t, initialBgDeps, finalBgDeps) + require.Equal(t, []string{Core}, finalBgDeps) }) t.Run("service without NamedService interface gets wrapped", func(t *testing.T) { - // Create a service that doesn't implement NamedService plainSvc := &mockService{} reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{plainSvc}} - adapter := NewManagerAdapter(reg) + adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{ + BackgroundServices: {Core}, + Core: {}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -151,11 +156,13 @@ func TestManagerAdapter_Starting(t *testing.T) { }) t.Run("service without CanBeDisabled interface is always enabled", func(t *testing.T) { - // Create a service that doesn't implement CanBeDisabled simpleSvc := &simpleBackgroundService{} reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{simpleSvc}} - adapter := NewManagerAdapter(reg) + adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{ + BackgroundServices: {Core}, + Core: {}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -168,36 +175,18 @@ func TestManagerAdapter_Starting(t *testing.T) { expectedServiceName := reflect.TypeOf(simpleSvc).String() require.Contains(t, adapter.dependencyMap, expectedServiceName) }) - - t.Run("real manager integration test", func(t *testing.T) { - testSvc := &mockService{} - reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{testSvc}} - adapter := NewManagerAdapter(reg) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - // Use the real manager - this tests actual integration - err := adapter.starting(ctx) - require.NoError(t, err) - require.NotNil(t, adapter.manager) - - // Verify the service was registered in dependency map - expectedServiceName := reflect.TypeOf(testSvc).String() - require.Contains(t, adapter.dependencyMap, expectedServiceName) - }) } func TestManagerAdapter_Running(t *testing.T) { - t.Run("runs with real manager", func(t *testing.T) { + t.Run("runs with manager", func(t *testing.T) { mock := &mockNamedService{name: "mock"} reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{ mock, }} - adapter := NewManagerAdapter(reg) - adapter.dependencyMap = map[string][]string{ - BackgroundServices: {}, - } + adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{ + BackgroundServices: {Core}, + Core: {}, + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -208,25 +197,6 @@ func TestManagerAdapter_Running(t *testing.T) { err = adapter.AwaitRunning(ctx) require.NoError(t, err) }) - - t.Run("running delegates to manager", func(t *testing.T) { - reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{}} - adapter := NewManagerAdapter(reg) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - // Initialize with real manager - err := adapter.starting(ctx) - require.NoError(t, err) - - // Test running method directly - this will likely fail due to missing production modules - // but it covers the running method code path - err = adapter.running(ctx) - if err != nil { - require.Contains(t, err.Error(), "no such module") - } - }) } func TestManagerAdapter_Stopping(t *testing.T) { @@ -262,7 +232,6 @@ func TestManagerAdapter_Stopping(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - // Initialize the manager first - need to go through starting to initialize manager err := adapter.starting(ctx) require.NoError(t, err) require.NotNil(t, adapter.manager) diff --git a/pkg/server/server.go b/pkg/server/server.go index 51a05827369..9996eb020e7 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,19 +2,14 @@ package server import ( "context" - "errors" "fmt" "net" "os" "path/filepath" - "reflect" "strconv" "sync" - "github.com/grafana/dskit/modules" "github.com/prometheus/client_golang/prometheus" - "go.opentelemetry.io/otel/trace" - "golang.org/x/sync/errgroup" "github.com/grafana/grafana/pkg/api" _ "github.com/grafana/grafana/pkg/extensions" @@ -24,9 +19,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry/backgroundsvcs/adapter" - "github.com/grafana/grafana/pkg/semconv" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/setting" ) @@ -66,18 +59,14 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR tracerProvider *tracing.TracingService, promReg prometheus.Registerer, ) (*Server, error) { - rootCtx, shutdownFn := context.WithCancel(context.Background()) - childRoutines, childCtx := errgroup.WithContext(rootCtx) + rootCtx := context.Background() s := &Server{ promReg: promReg, - context: childCtx, - childRoutines: childRoutines, + context: rootCtx, HTTPServer: httpServer, provisioningService: provisioningService, roleRegistry: roleRegistry, - shutdownFn: shutdownFn, - shutdownFinished: make(chan struct{}), log: log.New("server"), cfg: cfg, pidFile: opts.PidFile, @@ -86,6 +75,7 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR buildBranch: opts.BuildBranch, backgroundServiceRegistry: backgroundServiceProvider, tracerProvider: tracerProvider, + managerAdapter: adapter.NewManagerAdapter(backgroundServiceProvider), } return s, nil @@ -95,15 +85,12 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR // core Server implementation which starts the entire Grafana server. Use // ModuleServer to launch specific modules. type Server struct { - context context.Context - shutdownFn func() - childRoutines *errgroup.Group - log log.Logger - cfg *setting.Cfg - shutdownOnce sync.Once - shutdownFinished chan struct{} - isInitialized bool - mtx sync.Mutex + context context.Context + log log.Logger + cfg *setting.Cfg + shutdownOnce sync.Once + isInitialized bool + mtx sync.Mutex pidFile string version string @@ -117,6 +104,7 @@ type Server struct { roleRegistry accesscontrol.RoleRegistry provisioningService provisioning.ProvisioningService promReg prometheus.Registerer + managerAdapter *adapter.ManagerAdapter } // Init initializes the server and its services. @@ -145,83 +133,14 @@ func (s *Server) Init() error { } func (s *Server) Run() error { - if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagDskitBackgroundServices) { - s.log.Debug("Running background services with dskit wrapper") - return s.dskitRun() - } - s.log.Debug("Running standard background services") - return s.backgroundServicesRun() -} - -func (s *Server) dskitRun() error { - if err := s.Init(); err != nil { - return err - } - managerAdapter := adapter.NewManagerAdapter(s.backgroundServiceRegistry) - s.notifySystemd("READY=1") - - ctx, span := s.tracerProvider.Start(s.context, "server.dskitRun") - defer span.End() - - // override the shutdownFn (context cancel func) for now until the feature flag is removed. - // this is a temporary solution to ensure that the services are shutdown properly. - cancelFn := s.shutdownFn - s.shutdownFn = func() { - defer close(s.shutdownFinished) - s.log.Debug("Shutting down background services") - if err := managerAdapter.Shutdown(s.context, modules.ErrStopProcess.Error()); err != nil { - s.log.Error("Failed to shutdown background services", "error", err) - } - cancelFn() - } - - return managerAdapter.Run(ctx) -} - -func (s *Server) backgroundServicesRun() error { - ctx, span := s.tracerProvider.Start(s.context, "server.backgroundServicesRun") - defer span.End() - defer close(s.shutdownFinished) - if err := s.Init(); err != nil { return err } - services := s.backgroundServiceRegistry.GetServices() - - // Start background services. - for _, svc := range services { - if registry.IsDisabled(svc) { - continue - } - - service := svc - serviceName := reflect.TypeOf(service).String() - s.childRoutines.Go(func() error { - select { - case <-s.context.Done(): - return s.context.Err() - default: - } - s.log.Debug("Starting background service", "service", serviceName) - span.AddEvent(fmt.Sprintf("%s start", serviceName), trace.WithAttributes(semconv.GrafanaServiceName(serviceName))) - err := service.Run(ctx) - // Do not return context.Canceled error since errgroup.Group only - // returns the first error to the caller - thus we can miss a more - // interesting error. - if err != nil && !errors.Is(err, context.Canceled) { - s.log.Error("Stopped background service", "service", serviceName, "reason", err) - return fmt.Errorf("%s run error: %w", serviceName, err) - } - s.log.Debug("Stopped background service", "service", serviceName, "reason", err) - return nil - }) - } - + ctx, span := s.tracerProvider.Start(s.context, "server.Run") + defer span.End() s.notifySystemd("READY=1") - - s.log.Debug("Waiting on services...") - return s.childRoutines.Wait() + return s.managerAdapter.Run(ctx) } // Shutdown initiates Grafana graceful shutdown. This shuts down all @@ -231,15 +150,15 @@ func (s *Server) Shutdown(ctx context.Context, reason string) error { var err error s.shutdownOnce.Do(func() { s.log.Info("Shutdown started", "reason", reason) - // Call cancel func to stop background services. - s.shutdownFn() - // Wait for server to shut down + if shutdownErr := s.managerAdapter.Shutdown(ctx, "shutdown"); shutdownErr != nil { + s.log.Error("Failed to shutdown background services", "error", shutdownErr) + } select { - case <-s.shutdownFinished: - s.log.Debug("Finished waiting for server to shut down") case <-ctx.Done(): s.log.Warn("Timed out while waiting for server to shut down") err = fmt.Errorf("timeout waiting for shutdown") + default: + s.log.Debug("Finished waiting for server to shut down") } }) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index b64ef62851f..deefccbda90 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry/backgroundsvcs" + "github.com/grafana/grafana/pkg/registry/backgroundsvcs/adapter" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -52,6 +53,10 @@ func testServer(t *testing.T, services ...registry.BackgroundService) *Server { t.Helper() s, err := newServer(Options{}, setting.NewCfg(), nil, &acimpl.Service{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...), tracing.NewNoopTracerService(), prometheus.NewRegistry()) require.NoError(t, err) + s.managerAdapter.WithDependencies(map[string][]string{ + adapter.Core: {}, + adapter.BackgroundServices: {adapter.Core}, + }) // Required to skip configuration initialization that causes // DI errors in this test. s.isInitialized = true @@ -62,33 +67,28 @@ func TestServer_Run_Error(t *testing.T) { testErr := errors.New("boom") s := testServer(t, newTestService(nil, false), newTestService(testErr, false)) err := s.Run() - require.ErrorIs(t, err, testErr) + require.Error(t, err) + require.Contains(t, err.Error(), testErr.Error()) } func TestServer_Shutdown(t *testing.T) { - ctx := context.Background() + t.Run("successful shutdown", func(t *testing.T) { + ctx := context.Background() + s := testServer(t, newTestService(nil, false), newTestService(nil, true)) + ch := make(chan error) + go func() { + defer close(ch) + err := s.managerAdapter.AwaitRunning(ctx) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + err = s.Shutdown(ctx, "test interrupt") + ch <- err + }() + err := s.Run() + require.NoError(t, err) - s := testServer(t, newTestService(nil, false), newTestService(nil, true)) - - ch := make(chan error) - - go func() { - defer close(ch) - - // Wait until all services launched. - for _, svc := range s.backgroundServiceRegistry.GetServices() { - if !svc.(*testService).isDisabled { - <-svc.(*testService).started - } - } - ctx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - err := s.Shutdown(ctx, "test interrupt") - ch <- err - }() - err := s.Run() - require.NoError(t, err) - - err = <-ch - require.NoError(t, err) + err = <-ch + require.NoError(t, err) + }) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5d45f5394bf..925a399d39b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2027,16 +2027,6 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, - { - Name: "dskitBackgroundServices", - Description: "Enables dskit background service wrapper", - HideFromAdminPage: true, - HideFromDocs: true, - Stage: FeatureStageExperimental, - RequiresRestart: true, - Owner: grafanaPluginsPlatformSquad, - Expression: "false", - }, { Name: "pluginContainers", Description: "Enables running plugins in containers", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8eb68007bcb..6078461f2e9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -260,7 +260,6 @@ alertingTriage,experimental,@grafana/alerting-squad,false,false,true graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false -dskitBackgroundServices,experimental,@grafana/plugins-platform-backend,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false filterOutBotsFromFrontendLogs,experimental,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 931857dcdeb..1a117bcf5e9 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1051,10 +1051,6 @@ const ( // Checks for deprecated Prometheus authentication methods (SigV4 and Azure), installs the relevant data source, and migrates the Prometheus data sources FlagPrometheusTypeMigration = "prometheusTypeMigration" - // FlagDskitBackgroundServices - // Enables dskit background service wrapper - FlagDskitBackgroundServices = "dskitBackgroundServices" - // FlagPluginContainers // Enables running plugins in containers FlagPluginContainers = "pluginContainers" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index dc1b73b97b0..b208d3d5bae 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1245,6 +1245,7 @@ "name": "dskitBackgroundServices", "resourceVersion": "1757339637779", "creationTimestamp": "2025-09-03T12:20:24Z", + "deletionTimestamp": "2025-09-17T12:19:32Z", "annotations": { "grafana.app/updatedTimestamp": "2025-09-08 13:53:57.77994 +0000 UTC" }