Merge remote-tracking branch 'origin/main' into query-history-app
CodeQL checks / Detect whether code changed (push) Waiting to run Details
CodeQL checks / Analyze (actions) (push) Blocked by required conditions Details
CodeQL checks / Analyze (go) (push) Blocked by required conditions Details
CodeQL checks / Analyze (javascript) (push) Blocked by required conditions Details

This commit is contained in:
Ryan McKinley 2025-10-03 15:00:22 +03:00
commit 4fd234861c
10 changed files with 307 additions and 32 deletions

View File

@ -11,6 +11,8 @@ permissions: {}
jobs:
test:
name: Feature toggles documentation is in sync with source
runs-on: ubuntu-latest
permissions:

View File

@ -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 \

View File

@ -468,7 +468,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;

View File

@ -789,7 +789,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,

View File

@ -428,7 +428,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

View File

@ -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"

View File

@ -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<string, unknown>;
secureFields: Record<string, boolean>;
};
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<TestReceiverFormValues>({
defaultValues: {
name: 'test-contact-point',
items: [defaults],
},
});
return (
<AlertmanagerProvider accessType="notification">
<FormProvider {...form}>
<ChannelSubForm
defaultValues={defaults}
initialValues={initial}
pathPrefix={`items.0.`}
integrationIndex={0}
notifiers={notifiers}
onDuplicate={jest.fn()}
commonSettingsComponent={GrafanaCommonChannelSettings}
isEditable={true}
isTestable={false}
/>
</FormProvider>
</AlertmanagerProvider>
);
}
function renderForm(defaults: TestChannelValues, initial?: TestChannelValues) {
return render(<TestFormWrapper defaults={defaults} initial={initial} />);
}
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('');
});
});

View File

@ -62,6 +62,7 @@ export function ChannelSubForm<R extends ChannelValues>({
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<R extends ChannelValues>({
// 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<R extends ChannelValues>({
});
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<R extends ChannelValues>({
);
}
function getDefaultNotifierSettings(notifier: Notifier): Record<string, string> {
const defaultSettings: Record<string, string> = {};
notifier.dto.options.forEach((option) => {
if (option.defaultValue?.value) {
defaultSettings[option.propertyName] = option.defaultValue?.value;
}
});
return defaultSettings;
}
const getStyles = (theme: GrafanaTheme2) => ({
buttons: css({
'& > * + *': {

View File

@ -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({

View File

@ -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<Props & { id: string }> = ({
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<Props & { id: string }> = ({
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) {