mirror of https://github.com/grafana/grafana.git
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
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:
commit
4fd234861c
|
@ -11,6 +11,8 @@ permissions: {}
|
|||
|
||||
jobs:
|
||||
test:
|
||||
name: Feature toggles documentation is in sync with source
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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({
|
||||
'& > * + *': {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue