From c2bd641868c5faa9b763cdfcfb5035226bd2e4ab Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Fri, 3 Oct 2025 11:12:23 +0100 Subject: [PATCH 1/4] Fix mismatch in feature toggles documentation (#111975) --- packages/grafana-data/src/types/featureToggles.gen.ts | 2 +- pkg/services/featuremgmt/registry.go | 2 +- pkg/services/featuremgmt/toggles_gen.go | 2 +- pkg/services/featuremgmt/toggles_gen.json | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a47d3db8f8e..169d72c20fc 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -464,7 +464,7 @@ export interface FeatureToggles { */ alertingSaveStatePeriodic?: boolean; /** - * Enables the compressed protobuf-based alert state storage + * Enables the compressed protobuf-based alert state storage. Default is enabled. * @default true */ alertingSaveStateCompressed?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 54c66da34bc..98e838d9318 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -782,7 +782,7 @@ var ( }, { Name: "alertingSaveStateCompressed", - Description: "Enables the compressed protobuf-based alert state storage", + Description: "Enables the compressed protobuf-based alert state storage. Default is enabled.", Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: grafanaAlertingSquad, diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ba8b131f74f..501d47de58b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -424,7 +424,7 @@ const ( FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic" // FlagAlertingSaveStateCompressed - // Enables the compressed protobuf-based alert state storage + // Enables the compressed protobuf-based alert state storage. Default is enabled. FlagAlertingSaveStateCompressed = "alertingSaveStateCompressed" // FlagScopeApi diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index f2995c233b0..264edb84fab 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -555,14 +555,14 @@ { "metadata": { "name": "alertingSaveStateCompressed", - "resourceVersion": "1754657532777", + "resourceVersion": "1759485036332", "creationTimestamp": "2025-01-27T17:47:33Z", "annotations": { - "grafana.app/updatedTimestamp": "2025-08-08 12:52:12.777935 +0000 UTC" + "grafana.app/updatedTimestamp": "2025-10-03 09:50:36.332762 +0000 UTC" } }, "spec": { - "description": "Enables the compressed protobuf-based alert state storage", + "description": "Enables the compressed protobuf-based alert state storage. Default is enabled.", "stage": "preview", "codeowner": "@grafana/alerting-squad", "expression": "true" From 72a8dc488785841fe0e0310a158ec1201b10f673 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Fri, 3 Oct 2025 11:40:06 +0100 Subject: [PATCH 2/4] Give meaningful name to feature toggles documentation CI job (#111981) --- .github/workflows/feature-toggles-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/feature-toggles-ci.yml b/.github/workflows/feature-toggles-ci.yml index 67d1d37c739..d8808d86d67 100644 --- a/.github/workflows/feature-toggles-ci.yml +++ b/.github/workflows/feature-toggles-ci.yml @@ -11,6 +11,8 @@ permissions: {} jobs: test: + name: Feature toggles documentation is in sync with source + runs-on: ubuntu-latest permissions: From 97598e963be11503e36666944539a840793c0d46 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Fri, 3 Oct 2025 13:31:44 +0200 Subject: [PATCH 3/4] Alerting: Clear outdated settings when switching contact point type (#111869) * Reset integration settings when switching integration type * Remove comments * Remove unnecessary changes * Fix applying default values when switching contact point type * Fix non-secure settings overrides when switching contact points * Remove unused imports --- .../receivers/form/ChannelSubForm.test.tsx | 248 ++++++++++++++++++ .../receivers/form/ChannelSubForm.tsx | 45 +++- .../form/GrafanaReceiverForm.test.tsx | 2 +- .../receivers/form/fields/OptionField.tsx | 12 +- 4 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx new file mode 100644 index 00000000000..4fda03e9d53 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx @@ -0,0 +1,248 @@ +import 'core-js/stable/structured-clone'; +import { FormProvider, useForm } from 'react-hook-form'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { render } from 'test/test-utils'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers'; +import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; + +import { ChannelSubForm } from './ChannelSubForm'; +import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings'; +import { Notifier } from './notifiers'; + +type TestChannelValues = { + __id: string; + type: string; + settings: Record; + secureFields: Record; +}; + +type TestReceiverFormValues = { + name: string; + items: TestChannelValues[]; +}; + +const ui = { + typeSelector: byTestId('items.0.type'), + settings: { + webhook: { + url: byRole('textbox', { name: /^URL/ }), + optionalSettings: byRole('button', { name: /optional webhook settings/i }), + title: { + container: byTestId('items.0.settings.title'), + input: byRole('textbox', { name: /^Title/ }), + }, + message: { + container: byTestId('items.0.settings.message'), + input: byRole('textbox', { name: /^Message/ }), + }, + }, + slack: { + recipient: byTestId('items.0.settings.recipient'), + token: byTestId('items.0.settings.token'), + username: byTestId('items.0.settings.username'), + webhookUrl: byRole('textbox', { name: /^Webhook URL/ }), + }, + googlechat: { + optionalSettings: byRole('button', { name: /optional google hangouts chat settings/i }), + url: byRole('textbox', { name: /^URL/ }), + title: { + input: byRole('textbox', { name: /^Title/ }), + container: byTestId('items.0.settings.title'), + }, + message: { + input: byRole('textbox', { name: /^Message/ }), + container: byTestId('items.0.settings.message'), + }, + }, + }, +}; + +const notifiers: Notifier[] = [ + { dto: grafanaAlertNotifiers.webhook, meta: { enabled: true, order: 1 } }, + { dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } }, + { dto: grafanaAlertNotifiers.googlechat, meta: { enabled: true, order: 3 } }, + { dto: grafanaAlertNotifiers.sns, meta: { enabled: true, order: 4 } }, + { dto: grafanaAlertNotifiers.oncall, meta: { enabled: true, order: 5 } }, +]; + +describe('ChannelSubForm', () => { + function TestFormWrapper({ defaults, initial }: { defaults: TestChannelValues; initial?: TestChannelValues }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + function renderForm(defaults: TestChannelValues, initial?: TestChannelValues) { + return render(); + } + + it('switching type hides prior fields and shows new ones', async () => { + renderForm({ + __id: 'id-0', + type: 'webhook', + settings: { url: '' }, + secureFields: {}, + }); + + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + expect(ui.settings.webhook.url.get()).toBeInTheDocument(); + + expect(ui.settings.slack.recipient.query()).not.toBeInTheDocument(); + + await clickSelectOption(ui.typeSelector.get(), 'Slack'); + expect(ui.typeSelector.get()).toHaveTextContent('Slack'); + + expect(ui.settings.slack.recipient.get()).toBeInTheDocument(); + expect(ui.settings.slack.token.get()).toBeInTheDocument(); + expect(ui.settings.slack.username.get()).toBeInTheDocument(); + }); + + it('should clear secure fields when switching integration types', async () => { + const googlechatDefaults: TestChannelValues = { + __id: 'id-0', + type: 'googlechat', + settings: { title: 'Alert Title', message: 'Alert Message' }, + secureFields: { url: true }, + }; + + const { user } = renderForm(googlechatDefaults, googlechatDefaults); + + expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat'); + + expect(ui.settings.googlechat.url.get()).toBeDisabled(); + expect(ui.settings.googlechat.url.get()).toHaveValue('configured'); + + await user.click(ui.settings.googlechat.optionalSettings.get()); + + expect(ui.settings.googlechat.title.input.get()).toHaveValue('Alert Title'); + expect(ui.settings.googlechat.message.input.get()).toHaveValue('Alert Message'); + + await clickSelectOption(ui.typeSelector.get(), 'Webhook'); + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + // Webhook URL field should now be present and empty (settings cleared) + expect(ui.settings.webhook.url.get()).toHaveValue(''); + expect(ui.settings.webhook.title.container.get()).toBeInTheDocument(); + expect(ui.settings.webhook.message.container.get()).toBeInTheDocument(); + + // If value for templated fields is empty the input should not be present + expect(ui.settings.webhook.message.input.query()).not.toBeInTheDocument(); + expect(ui.settings.webhook.title.input.query()).not.toBeInTheDocument(); + }); + + it('should clear settings when switching from webhook to googlechat', async () => { + const webhookDefaults: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + settings: { url: 'https://example.com/webhook', title: 'Webhook Title', message: 'Webhook Message' }, + secureFields: {}, + }; + + const { user } = renderForm(webhookDefaults, webhookDefaults); + + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + expect(ui.settings.webhook.url.get()).toHaveValue('https://example.com/webhook'); + + await user.click(ui.settings.webhook.optionalSettings.get()); + expect(ui.settings.webhook.title.input.get()).toHaveValue('Webhook Title'); + expect(ui.settings.webhook.message.input.get()).toHaveValue('Webhook Message'); + + await clickSelectOption(ui.typeSelector.get(), 'Google Hangouts Chat'); + expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat'); + + // Google Chat URL field should now be present and empty (settings cleared) + expect(ui.settings.googlechat.url.get()).toHaveValue(''); + expect(ui.settings.googlechat.title.container.get()).toBeInTheDocument(); + expect(ui.settings.googlechat.message.container.get()).toBeInTheDocument(); + + // If value for templated fields is empty the input should not be present + expect(ui.settings.googlechat.message.input.query()).not.toBeInTheDocument(); + expect(ui.settings.googlechat.title.input.query()).not.toBeInTheDocument(); + }); + + it('should restore initial values when switching back to original type', async () => { + const googlechatDefaults: TestChannelValues = { + __id: 'id-0', + type: 'googlechat', + settings: { title: 'Original Title', message: 'Original Message' }, + secureFields: { url: true }, + }; + + const { user } = renderForm(googlechatDefaults, googlechatDefaults); + + expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat'); + + expect(ui.settings.googlechat.url.get()).toBeDisabled(); + expect(ui.settings.googlechat.url.get()).toHaveValue('configured'); + + await user.click(ui.settings.googlechat.optionalSettings.get()); + + expect(ui.settings.googlechat.title.input.get()).toHaveValue('Original Title'); + expect(ui.settings.googlechat.message.input.get()).toHaveValue('Original Message'); + + // Switch to a different type + await clickSelectOption(ui.typeSelector.get(), 'Webhook'); + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + expect(ui.settings.webhook.url.get()).toHaveValue(''); + + // Switch back to the original type + await clickSelectOption(ui.typeSelector.get(), 'Google Hangouts Chat'); + expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat'); + + // Original settings and secure fields should be restored + expect(ui.settings.googlechat.url.get()).toBeDisabled(); + expect(ui.settings.googlechat.url.get()).toHaveValue('configured'); + + expect(ui.settings.googlechat.title.input.get()).toHaveValue('Original Title'); + expect(ui.settings.googlechat.message.input.get()).toHaveValue('Original Message'); + }); + + it('should maintain secure field isolation across multiple type switches', async () => { + const googlechatDefaults: TestChannelValues = { + __id: 'id-0', + type: 'googlechat', + settings: {}, + secureFields: { url: true }, + }; + + renderForm(googlechatDefaults, googlechatDefaults); + + expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat'); + expect(ui.settings.googlechat.url.get()).toBeDisabled(); + expect(ui.settings.googlechat.url.get()).toHaveValue('configured'); + + // Switch to Slack + await clickSelectOption(ui.typeSelector.get(), 'Slack'); + expect(ui.typeSelector.get()).toHaveTextContent('Slack'); + + // Slack should not have any secure fields from Google Chat + const slackUrl = ui.settings.slack.webhookUrl.get(); + expect(slackUrl).toBeEnabled(); + expect(slackUrl).toHaveValue(''); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index ca939d25d38..499fd7bdaee 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -62,6 +62,7 @@ export function ChannelSubForm({ const channelFieldPath = `items.${integrationIndex}` as const; const typeFieldPath = `${channelFieldPath}.type` as const; const settingsFieldPath = `${channelFieldPath}.settings` as const; + const secureFieldsPath = `${channelFieldPath}.secureFields` as const; const selectedType = watch(typeFieldPath) ?? defaultValues.type; const parse_mode = watch(`${settingsFieldPath}.parse_mode`); @@ -83,10 +84,28 @@ export function ChannelSubForm({ // Restore values when switching back from a changed integration to the default one const subscription = watch((formValues, { name, type }) => { // @ts-expect-error name is valid key for formValues - const value = name ? formValues[name] : ''; + const value = name ? getValues(name, formValues) : ''; if (initialValues && name === typeFieldPath && value === initialValues.type && type === 'change') { setValue(settingsFieldPath, initialValues.settings); + setValue(secureFieldsPath, initialValues.secureFields); + } else if (name === typeFieldPath && type === 'change') { + // When switching to a new notifier, set the default settings to remove all existing settings + // from the previous notifier + const newNotifier = notifiers.find(({ dto: { type } }) => type === value); + const defaultNotifierSettings = newNotifier ? getDefaultNotifierSettings(newNotifier) : {}; + + // Not sure why, but verriding settingsFieldPath is not enough if notifiers have the same settings fields, like url, title + const currentSettings = getValues(settingsFieldPath) ?? {}; + Object.keys(currentSettings).forEach((key) => { + if (!defaultNotifierSettings[key]) { + setValue(`${settingsFieldPath}.${key}`, defaultNotifierSettings[key]); + } + }); + + setValue(settingsFieldPath, defaultNotifierSettings); + setValue(secureFieldsPath, {}); } + // Restore initial value of an existing oncall integration if ( initialValues && @@ -98,7 +117,19 @@ export function ChannelSubForm({ }); return () => subscription.unsubscribe(); - }, [selectedType, initialValues, setValue, settingsFieldPath, typeFieldPath, watch]); + }, [ + selectedType, + initialValues, + setValue, + settingsFieldPath, + typeFieldPath, + secureFieldsPath, + getValues, + watch, + defaultValues.settings, + defaultValues.secureFields, + notifiers, + ]); const onResetSecureField = (key: string) => { // formSecureFields might not be up to date if this function is called multiple times in a row @@ -294,6 +325,16 @@ export function ChannelSubForm({ ); } +function getDefaultNotifierSettings(notifier: Notifier): Record { + const defaultSettings: Record = {}; + notifier.dto.options.forEach((option) => { + if (option.defaultValue?.value) { + defaultSettings[option.propertyName] = option.defaultValue?.value; + } + }); + return defaultSettings; +} + const getStyles = (theme: GrafanaTheme2) => ({ buttons: css({ '& > * + *': { diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx index af23e5dc423..e07d314f6a0 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx @@ -387,7 +387,7 @@ describe('GrafanaReceiverForm', () => { await user.click(newIntegrationRadio.get()); expect(newIntegrationRadio.get()).toBeChecked(); - await user.type(ui.newOnCallIntegrationName.get(), 'emea-oncall'); + await user.type(await ui.newOnCallIntegrationName.find(), 'emea-oncall'); // eslint-disable-next-line testing-library/no-node-access expect(ui.integrationType.get().closest('form')).toHaveFormValues({ diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx index 955d0bae209..d965fafb40d 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { FC, useEffect } from 'react'; +import { FC } from 'react'; import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; @@ -116,7 +116,7 @@ const OptionInput: FC = ({ getOptionMeta, }) => { const styles = useStyles2(getStyles); - const { control, register, unregister, setValue } = useFormContext(); + const { control, register, setValue } = useFormContext(); const optionMeta = getOptionMeta?.(option); @@ -125,14 +125,6 @@ const OptionInput: FC = ({ const secureFieldKey = option.secure && option.secureFieldKey ? option.secureFieldKey : ''; const isEncryptedInput = secureFieldKey && secureFields?.[secureFieldKey]; - // workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506 - useEffect( - () => () => { - unregister(name, { keepValue: false }); - }, - [unregister, name] - ); - const useTemplates = option.placeholder.includes('{{ template'); function onSelectTemplate(template: string) { From a029c38d2515520961cb1cd002f244b779894d83 Mon Sep 17 00:00:00 2001 From: Eloy Moreno Date: Fri, 3 Oct 2025 13:38:11 +0200 Subject: [PATCH 4/4] Actions: use create-github-app-token in pr-e2e-tests.yml workflow (#111804) --- .github/workflows/pr-e2e-tests.yml | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 6a61f3bf5ca..6ade5a9c787 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -149,19 +149,11 @@ jobs: needs: - build-grafana steps: - - id: vault-secrets - uses: grafana/shared-workflows/actions/get-vault-secrets@main + - id: get-github-token + name: "create github app token" + uses: grafana/shared-workflows/actions/create-github-app-token@eb02241ed0a92aff205feab8ac3afcdf51c757c8 # create-github-app-token-v0.2.0 with: - repo_secrets: | - GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY - - name: Generate token - id: generate_token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a - with: - app_id: ${{ vars.DELIVERY_BOT_APP_ID }} - private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }} - repositories: '["grafana"]' - permissions: '{"checks": "write"}' + github_app: "delivery-bot-app" - uses: grafana/shared-workflows/actions/login-to-gar@main id: login-to-gar with: @@ -184,7 +176,7 @@ jobs: echo "IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV" - name: Add PR status check env: - GH_TOKEN: ${{ steps.generate_token.outputs.token }} + GH_TOKEN: ${{ steps.get-github-token.outputs.token }} SHA: ${{ github.event.pull_request.head.sha }} run: | gh api \