diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 6f9a99194cc..b0c41c84acd 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }), grafanaNotifiers: build.query({ - query: () => ({ url: '/api/alert-notifiers' }), + // NOTE: version=2 parameter required for versioned schema (PR #109969) + // This parameter will be removed in future when v2 becomes default + query: () => ({ url: '/api/alert-notifiers?version=2' }), transformResponse: (response: NotifierDTO[]) => { const populateSecureFieldKey = ( option: NotificationChannelOption, @@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ ), }); + // Keep versions array intact for version-specific options lookup + // Transform options with secureFieldKey population return response.map((notifier) => ({ ...notifier, - options: notifier.options.map((option) => { - return populateSecureFieldKey(option, ''); - }), + options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')), + // Also transform options within each version + versions: notifier.versions?.map((version) => ({ + ...version, + options: (version.options || []).map((option) => populateSecureFieldKey(option, '')), + })), })); }, }), diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index 73beb8a0865..7a88d1e21d7 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -36,6 +36,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) ); }; +export const ImportedContactPointAlert = (props: ExtraAlertProps) => { + return ( + + + This contact point contains integrations that were imported from an external Alertmanager and is currently + read-only. The integrations will become editable after the migration process is complete. + + + ); +}; + export const ProvisioningBadge = ({ tooltip, provenance, 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 index 00a933acec3..76de0099416 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.test.tsx @@ -1,11 +1,12 @@ 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 { render, screen } 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 { NotifierDTO } from 'app/features/alerting/unified/types/alerting'; import { ChannelSubForm } from './ChannelSubForm'; import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings'; @@ -16,6 +17,7 @@ type TestChannelValues = { type: string; settings: Record; secureFields: Record; + version?: string; }; type TestReceiverFormValues = { @@ -246,4 +248,241 @@ describe('ChannelSubForm', () => { expect(slackUrl).toBeEnabled(); expect(slackUrl).toHaveValue(''); }); + + describe('version-specific options display', () => { + // Create a mock notifier with different options for v0 and v1 + const legacyOptions = [ + { + element: 'input' as const, + inputType: 'text', + label: 'Legacy URL', + description: 'The legacy endpoint URL', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const webhookWithVersions: NotifierDTO = { + ...grafanaAlertNotifiers.webhook, + versions: [ + { + version: 'v0mimir1', + label: 'Webhook (Legacy)', + description: 'Legacy webhook from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v0mimir2', + label: 'Webhook (Legacy v2)', + description: 'Legacy webhook v2 from Mimir', + canCreate: false, + options: legacyOptions, + }, + { + version: 'v1', + label: 'Webhook', + description: 'Sends HTTP POST request', + canCreate: true, + options: grafanaAlertNotifiers.webhook.options, + }, + ], + }; + + const versionedNotifiers: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } }, + ]; + + function VersionedTestFormWrapper({ + defaults, + initial, + }: { + defaults: TestChannelValues; + initial?: TestChannelValues; + }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) { + return render(); + } + + it('should display v1 options when integration has v1 version', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should show v1 URL field (from default options) + expect(ui.settings.webhook.url.get()).toBeInTheDocument(); + // Should NOT show legacy URL field + expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument(); + }); + + it('should display v0 options when integration has legacy version', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show legacy URL field (from v0 options) + expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument(); + // Should NOT show v1 URL field + expect(ui.settings.webhook.url.query()).not.toBeInTheDocument(); + }); + + it('should display "Legacy" badge for v0mimir1 integration', () => { + const webhookV0: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir1', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0, webhookV0); + + // Should show "Legacy" badge for v0mimir1 integrations + expect(screen.getByText('Legacy')).toBeInTheDocument(); + }); + + it('should display "Legacy v2" badge for v0mimir2 integration', () => { + const webhookV0v2: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v0mimir2', + settings: { legacyUrl: 'https://legacy.example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV0v2, webhookV0v2); + + // Should show "Legacy v2" badge for v0mimir2 integrations + expect(screen.getByText('Legacy v2')).toBeInTheDocument(); + }); + + it('should NOT display version badge for v1 integration', () => { + const webhookV1: TestChannelValues = { + __id: 'id-0', + type: 'webhook', + version: 'v1', + settings: { url: 'https://example.com' }, + secureFields: {}, + }; + + renderVersionedForm(webhookV1, webhookV1); + + // Should NOT show version badge for non-legacy v1 integrations + expect(screen.queryByText('v1')).not.toBeInTheDocument(); + }); + + it('should filter out notifiers with canCreate: false from dropdown', () => { + // Create a notifier that only has v0 versions (cannot be created) + const legacyOnlyNotifier: NotifierDTO = { + type: 'wechat', + name: 'WeChat', + heading: 'WeChat settings', + description: 'Sends notifications to WeChat', + options: [], + versions: [ + { + version: 'v0mimir1', + label: 'WeChat (Legacy)', + description: 'Legacy WeChat', + canCreate: false, + options: [], + }, + ], + }; + + const notifiersWithLegacyOnly: Notifier[] = [ + { dto: webhookWithVersions, meta: { enabled: true, order: 1 } }, + { dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } }, + ]; + + function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) { + const form = useForm({ + defaultValues: { + name: 'test-contact-point', + items: [defaults], + }, + }); + + return ( + + + + + + ); + } + + render( + + ); + + // Webhook should be in dropdown (has v1 with canCreate: true) + expect(ui.typeSelector.get()).toHaveTextContent('Webhook'); + + // WeChat should NOT be in the options (only has v0 with canCreate: false) + // We can't easily check dropdown options without opening it, but the filter should work + }); + }); }); 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 c49b5184623..cb1d79025f8 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; import { @@ -16,6 +16,12 @@ import { GrafanaChannelValues, ReceiverFormValues, } from '../../../types/receiver-form'; +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + isLegacyVersion, +} from '../../../utils/notifier-versions'; import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; import { ChannelOptions } from './ChannelOptions'; @@ -62,6 +68,7 @@ export function ChannelSubForm({ const channelFieldPath = `items.${integrationIndex}` as const; const typeFieldPath = `${channelFieldPath}.type` as const; + const versionFieldPath = `${channelFieldPath}.version` as const; const settingsFieldPath = `${channelFieldPath}.settings` as const; const secureFieldsPath = `${channelFieldPath}.secureFields` as const; @@ -104,6 +111,9 @@ export function ChannelSubForm({ setValue(settingsFieldPath, defaultNotifierSettings); setValue(secureFieldsPath, {}); + + // Reset version when changing type - backend will use its default + setValue(versionFieldPath, undefined); } // Restore initial value of an existing oncall integration @@ -123,6 +133,7 @@ export function ChannelSubForm({ setValue, settingsFieldPath, typeFieldPath, + versionFieldPath, secureFieldsPath, getValues, watch, @@ -164,24 +175,30 @@ export function ChannelSubForm({ setValue(`${settingsFieldPath}.${fieldPath}`, undefined); }; - const typeOptions = useMemo( - (): SelectableValue[] => - sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( - ({ dto: { name, type }, meta }) => ({ - // @ts-expect-error ReactNode is supported + const typeOptions = useMemo((): SelectableValue[] => { + // Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat) + // These are legacy integrations that only exist in Mimir and can't be created in Grafana + const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto)); + + return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map( + ({ dto: { name, type }, meta }) => { + return { + // ReactNode is supported in Select label, but types don't reflect it + /* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ label: ( {name} {meta?.badge} - ), + ) as any, + /* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ value: type, description: meta?.description, isDisabled: meta ? !meta.enabled : false, - }) - ), - [notifiers] - ); + }; + } + ); + }, [notifiers]); const handleTest = async () => { await trigger(); @@ -198,10 +215,21 @@ export function ChannelSubForm({ // Cloud AM takes no value at all const isParseModeNone = parse_mode === 'None' || !parse_mode; const showTelegramWarning = isTelegram && !isParseModeNone; + + // Check if current integration is a legacy version (canCreate: false) + // Legacy integrations are read-only and cannot be edited + // Read version from existing integration data (stored in receiver config) + const integrationVersion = initialValues?.version || defaultValues.version; + const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false; + + // Get the correct options based on the integration's version + // This ensures legacy (v0) integrations display the correct schema + const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : []; + // if there are mandatory options defined, optional options will be hidden by a collapse // if there aren't mandatory options, all options will be shown without collapse - const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? []; - const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? []; + const mandatoryOptions = versionedOptions.filter((o) => o.required); + const optionalOptions = versionedOptions.filter((o) => !o.required); const contactPointTypeInputId = `contact-point-type-${pathPrefix}`; return ( @@ -214,21 +242,35 @@ export function ChannelSubForm({ data-testid={`${pathPrefix}type`} noMargin > - ( - onChange(value?.value)} + /> + )} + /> + {isLegacy && integrationVersion && ( + )} - /> +
@@ -292,7 +334,7 @@ export function ChannelSubForm({ name: notifier.dto.name, })} > - {notifier.dto.info !== '' && ( + {notifier.dto.info && ( {notifier.dto.info} diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index df68ab185dc..f103589cb9b 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -18,12 +18,13 @@ import { import { alertmanagerApi } from '../../../api/alertmanagerApi'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; +import { hasLegacyIntegrations } from '../../../utils/notifier-versions'; import { formChannelValuesToGrafanaChannelConfig, formValuesToGrafanaReceiver, grafanaReceiverToFormValues, } from '../../../utils/receiver-form'; -import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; +import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall'; import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; @@ -39,6 +40,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({ secureFields: {}, disableResolveMessage: false, type: 'email', + // version is intentionally not set here - it will be determined by the notifier's currentVersion + // when the integration is created/type is changed. The backend will use its default if not provided. }); interface Props { @@ -67,7 +70,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } = useOnCallIntegration(); const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery(); - const [testReceivers, setTestReceivers] = useState(); // transform receiver DTO to form values @@ -135,15 +137,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } ); } + // Map notifiers to Notifier[] format for ReceiverForm + // The grafanaNotifiers include version-specific options via the versions array from the backend + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions const notifiers: Notifier[] = grafanaNotifiers.map((n) => { if (n.type === ReceiverTypes.OnCall) { return { - dto: extendOnCallNotifierFeatures(n), + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + dto: extendOnCallNotifierFeatures(n as any) as any, meta: onCallNotifierMeta, }; } - return { dto: n }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + return { dto: n as any }; }); return ( @@ -163,7 +170,12 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } )} - {contactPoint?.provisioned && } + {contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} + {contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + + )} contactPointId={contactPoint?.id} diff --git a/public/app/features/alerting/unified/types/alerting.ts b/public/app/features/alerting/unified/types/alerting.ts index c6fe667982a..6436f132391 100644 --- a/public/app/features/alerting/unified/types/alerting.ts +++ b/public/app/features/alerting/unified/types/alerting.ts @@ -80,6 +80,20 @@ export type CloudNotifierType = | 'jira'; export type NotifierType = GrafanaNotifierType | CloudNotifierType; + +/** + * Represents a specific version of a notifier integration + * Used for integration versioning during Single Alert Manager migration + */ +export interface NotifierVersion { + version: string; + label: string; + description: string; + options: NotificationChannelOption[]; + /** Whether this version can be used to create new integrations */ + canCreate?: boolean; +} + export interface NotifierDTO { name: string; description: string; @@ -88,6 +102,23 @@ export interface NotifierDTO { options: NotificationChannelOption[]; info?: string; secure?: boolean; + /** + * Available versions for this notifier from the backend + * Each version contains version-specific options and metadata + */ + versions?: NotifierVersion[]; + /** + * The default version that the backend will use when creating new integrations. + * Returned by the backend from /api/alert-notifiers?version=2 + * + * - "v1" for most notifiers (modern Grafana version) + * - "v0mimir1" for legacy-only notifiers (e.g., WeChat) + * + * Note: Currently not used in the frontend. The backend handles version + * selection automatically. Could be used in the future to display + * version information or validate notifier capabilities. + */ + currentVersion?: string; } export interface NotificationChannelType { diff --git a/public/app/features/alerting/unified/types/receiver-form.ts b/public/app/features/alerting/unified/types/receiver-form.ts index 09d87d06845..0e5c95f04ae 100644 --- a/public/app/features/alerting/unified/types/receiver-form.ts +++ b/public/app/features/alerting/unified/types/receiver-form.ts @@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray'; export interface ChannelValues { __id: string; // used to correlate form values to original DTOs type: string; + version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana) settings: Record; secureFields: Record; } diff --git a/public/app/features/alerting/unified/utils/notifier-versions.test.ts b/public/app/features/alerting/unified/utils/notifier-versions.test.ts new file mode 100644 index 00000000000..d11ac74665d --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.test.ts @@ -0,0 +1,429 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting'; + +import { + canCreateNotifier, + getLegacyVersionLabel, + getOptionsForVersion, + hasLegacyIntegrations, + isLegacyVersion, +} from './notifier-versions'; + +// Helper to create a minimal NotifierDTO for testing +function createNotifier(overrides: Partial = {}): NotifierDTO { + return { + name: 'Test Notifier', + description: 'Test description', + type: 'webhook', + heading: 'Test heading', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Default Option', + description: 'Default option description', + placeholder: '', + propertyName: 'defaultOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +// Helper to create a NotifierVersion for testing +function createVersion(overrides: Partial = {}): NotifierVersion { + return { + version: 'v1', + label: 'Test Version', + description: 'Test version description', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Version Option', + description: 'Version option description', + placeholder: '', + propertyName: 'versionOption', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ], + ...overrides, + }; +} + +describe('notifier-versions utilities', () => { + describe('canCreateNotifier', () => { + it('should return true if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return true if at least one version has canCreate: undefined (defaults to true)', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: undefined }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(true); + }); + + it('should return false if all versions have canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + ], + }); + expect(canCreateNotifier(notifier)).toBe(false); + }); + + it('should return false for notifiers like WeChat that only have legacy versions', () => { + const wechatNotifier = createNotifier({ + name: 'WeChat', + type: 'wechat', + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(canCreateNotifier(wechatNotifier)).toBe(false); + }); + }); + + describe('isLegacyVersion', () => { + it('should return false if no version is specified', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v0mimir1', canCreate: false })], + }); + expect(isLegacyVersion(notifier, undefined)).toBe(false); + expect(isLegacyVersion(notifier, '')).toBe(false); + }); + + it('should return false if notifier has no versions array', () => { + const notifier = createNotifier({ versions: undefined }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if notifier has empty versions array', () => { + const notifier = createNotifier({ versions: [] }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version is not found in versions array', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false); + }); + + it('should return false if version has canCreate: true', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: true })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return false if version has canCreate: undefined', () => { + const notifier = createNotifier({ + versions: [createVersion({ version: 'v1', canCreate: undefined })], + }); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + + it('should return true if version has canCreate: false', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + }); + + it('should correctly identify legacy versions in a mixed notifier', () => { + const notifier = createNotifier({ + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }); + expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true); + expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true); + expect(isLegacyVersion(notifier, 'v1')).toBe(false); + }); + }); + + describe('getOptionsForVersion', () => { + const defaultOptions: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Default URL', + description: 'Default URL description', + placeholder: '', + propertyName: 'url', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v0Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Legacy URL', + description: 'Legacy URL description', + placeholder: '', + propertyName: 'legacyUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + const v1Options: NotificationChannelOption[] = [ + { + element: 'input', + inputType: 'text', + label: 'Modern URL', + description: 'Modern URL description', + placeholder: '', + propertyName: 'modernUrl', + required: true, + secure: false, + showWhen: { field: '', is: '' }, + validationRule: '', + dependsOn: '', + }, + ]; + + it('should return options from default creatable version if no version is specified', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // When no version specified, should use options from the default creatable version + expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options); + }); + + it('should return default options if no version is specified and empty string is passed', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })], + }); + // Empty string is still a falsy version, so should use default creatable version + expect(getOptionsForVersion(notifier, '')).toBe(v1Options); + }); + + it('should return default options if notifier has no versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: undefined, + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if notifier has empty versions array', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + + it('should return default options if version is not found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [createVersion({ version: 'v1', options: v1Options })], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions); + }); + + it('should return version-specific options when version is found', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + createVersion({ version: 'v0mimir1', options: v0Options }), + createVersion({ version: 'v1', options: v1Options }), + ], + }); + expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options); + expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options); + }); + + it('should return default options if version found but has no options', () => { + const notifier = createNotifier({ + options: defaultOptions, + versions: [ + { + version: 'v1', + label: 'V1', + description: 'V1 description', + options: undefined as unknown as NotificationChannelOption[], + }, + ], + }); + expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions); + }); + }); + + describe('hasLegacyIntegrations', () => { + // Helper to create a minimal contact point for testing + function createContactPoint(overrides: Partial = {}): GrafanaManagedContactPoint { + return { + name: 'Test Contact Point', + ...overrides, + }; + } + + // Create notifiers with version info for testing + const notifiersWithVersions: NotifierDTO[] = [ + createNotifier({ + type: 'slack', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + createNotifier({ + type: 'webhook', + versions: [ + createVersion({ version: 'v0mimir1', canCreate: false }), + createVersion({ version: 'v0mimir2', canCreate: false }), + createVersion({ version: 'v1', canCreate: true }), + ], + }), + ]; + + it('should return false if contact point is undefined', () => { + expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false); + }); + + it('should return false if notifiers is undefined', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false); + }); + + it('should return false if contact point has no integrations', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if contact point has empty integrations array', () => { + const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have v1 version (canCreate: true)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return false if all integrations have no version', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {} }, + { type: 'webhook', settings: {} }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + + it('should return true if any integration has a legacy version (canCreate: false)', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v1' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return true if all integrations have legacy versions', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [ + { type: 'slack', settings: {}, version: 'v0mimir1' }, + { type: 'webhook', settings: {}, version: 'v0mimir2' }, + ], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true); + }); + + it('should return false if notifier type is not found in notifiers array', () => { + const contactPoint = createContactPoint({ + grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }], + }); + expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false); + }); + }); + + describe('getLegacyVersionLabel', () => { + it('should return "Legacy" for undefined version', () => { + expect(getLegacyVersionLabel(undefined)).toBe('Legacy'); + }); + + it('should return "Legacy" for empty string version', () => { + expect(getLegacyVersionLabel('')).toBe('Legacy'); + }); + + it('should return "Legacy" for v0mimir1', () => { + expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v0mimir2', () => { + expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2'); + }); + + it('should return "Legacy v3" for v0mimir3', () => { + expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3'); + }); + + it('should return "Legacy" for v1 (trailing 1)', () => { + expect(getLegacyVersionLabel('v1')).toBe('Legacy'); + }); + + it('should return "Legacy v2" for v2 (trailing 2)', () => { + expect(getLegacyVersionLabel('v2')).toBe('Legacy v2'); + }); + + it('should return "Legacy" for version strings without trailing number', () => { + expect(getLegacyVersionLabel('legacy')).toBe('Legacy'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/notifier-versions.ts b/public/app/features/alerting/unified/utils/notifier-versions.ts new file mode 100644 index 00000000000..b4e3b7902b1 --- /dev/null +++ b/public/app/features/alerting/unified/utils/notifier-versions.ts @@ -0,0 +1,126 @@ +/** + * Utilities for integration versioning + * + * These utilities help get version-specific options from the backend response + * (via /api/alert-notifiers?version=2) + */ + +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { NotificationChannelOption, NotifierDTO } from '../types/alerting'; + +/** + * Checks if a notifier can be used to create new integrations. + * A notifier can be created if it has at least one version with canCreate: true, + * or if it has no versions array (legacy behavior). + * + * @param notifier - The notifier DTO to check + * @returns True if the notifier can be used to create new integrations + */ +export function canCreateNotifier(notifier: NotifierDTO): boolean { + // If no versions array, assume it can be created (legacy behavior) + if (!notifier.versions || notifier.versions.length === 0) { + return true; + } + + // Check if any version has canCreate: true (or undefined, which defaults to true) + return notifier.versions.some((v) => v.canCreate !== false); +} + +/** + * Checks if a specific version is legacy (cannot be created). + * A version is legacy if it has canCreate: false in the notifier's versions array. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version string to check (e.g., 'v0mimir1', 'v1') + * @returns True if the version is legacy (canCreate: false) + */ +export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean { + // If no version specified or no versions array, it's not legacy + if (!version || !notifier.versions || notifier.versions.length === 0) { + return false; + } + + // Find the matching version and check its canCreate property + const versionData = notifier.versions.find((v) => v.version === version); + + // A version is legacy if canCreate is explicitly false + return versionData?.canCreate === false; +} + +/** + * Gets the options for a specific version of a notifier. + * Used to display the correct form fields based on integration version. + * + * @param notifier - The notifier DTO containing versions array + * @param version - The version to get options for (e.g., 'v0', 'v1') + * @returns The options for the specified version, or default options if version not found + */ +export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] { + // If no versions array, use default options + if (!notifier.versions || notifier.versions.length === 0) { + return notifier.options; + } + + // If version is specified, find the matching version + if (version) { + const versionData = notifier.versions.find((v) => v.version === version); + // Return version-specific options if found, otherwise fall back to default + return versionData?.options ?? notifier.options; + } + + // If no version specified, find the default creatable version (canCreate !== false) + const defaultVersion = notifier.versions.find((v) => v.canCreate !== false); + return defaultVersion?.options ?? notifier.options; +} + +/** + * Checks if a contact point has any legacy (imported) integrations. + * A contact point has legacy integrations if any of its integrations uses a version + * with canCreate: false in the corresponding notifier's versions array. + * + * @param contactPoint - The contact point to check + * @param notifiers - Array of notifier DTOs to look up version info + * @returns True if the contact point has at least one legacy/imported integration + */ +export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean { + if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) { + return false; + } + + return contactPoint.grafana_managed_receiver_configs.some((config) => { + const notifier = notifiers.find((n) => n.type === config.type); + return notifier ? isLegacyVersion(notifier, config.version) : false; + }); +} + +/** + * Gets a user-friendly label for a legacy version. + * Extracts the version number from the version string and formats it as: + * - "Legacy" for version 1 (e.g., v0mimir1) + * - "Legacy v2" for version 2 (e.g., v0mimir2) + * - etc. + * + * Precondition: This function assumes the version is already known to be legacy + * (i.e., canCreate: false). Use isLegacyVersion() to check before calling this. + * + * @param version - The version string (e.g., 'v0mimir1', 'v0mimir2') + * @returns A user-friendly label like "Legacy" or "Legacy v2" + */ +export function getLegacyVersionLabel(version?: string): string { + if (!version) { + return 'Legacy'; + } + + // Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2) + const match = version.match(/(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num === 1) { + return 'Legacy'; + } + return `Legacy v${num}`; + } + + return 'Legacy'; +} diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index bea5503ad85..31927a1080a 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues( const values: GrafanaChannelValues = { __id: id, type: channel.type as NotifierType, + version: channel.version, provenance: channel.provenance, settings: { ...channel.settings }, secureFields: { ...channel.secureFields }, @@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig( }), secureFields: secureFieldsFromValues, type: values.type, + version: values.version ?? existing?.version, name, disableResolveMessage: values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage, diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ef56b44c767..3fb512c88c6 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = { // SecureSettings?: GrafanaManagedReceiverConfigSettings; settings: GrafanaManagedReceiverConfigSettings; type: string; + /** + * Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana) + */ + version?: string; /** * Name of the _receiver_, which in most cases will be the * same as the contact point's name. This should not be used, and is optional because the diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4b99a5811e7..a5545957399 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -807,7 +807,8 @@ "label-integration": "Integration", "label-notification-settings": "Notification settings", "label-section": "Optional {{name}} settings", - "test": "Test" + "test": "Test", + "tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified." }, "classic-condition-viewer": { "of": "OF", @@ -2176,7 +2177,9 @@ "provisioning": { "badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI", "badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI", + "body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.", "body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.", + "title-imported": "This contact point was imported and cannot be edited through the UI", "title-provisioned": "This {{resource}} cannot be edited through the UI" }, "provisioning-badge": {