diff --git a/public/app/features/alerting/unified/components/Provisioning.test.tsx b/public/app/features/alerting/unified/components/Provisioning.test.tsx new file mode 100644 index 00000000000..4519a8db133 --- /dev/null +++ b/public/app/features/alerting/unified/components/Provisioning.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from 'test/test-utils'; + +import { KnownProvenance } from '../types/knownProvenance'; + +import { ProvisioningBadge } from './Provisioning'; + +describe('ProvisioningBadge', () => { + describe('when the provenance is file', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via file and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is ConvertedPrometheus', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + expect(screen.queryByText('Provisioned')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Imported'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via Prometheus/Mimir and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); + + describe('when the provenance is API', () => { + it('should render the badge with the correct text', () => { + render(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + expect(screen.queryByText('Imported')).not.toBeInTheDocument(); + }); + + it('should render correct tooltip text', async () => { + const { user } = render(); + + const badge = screen.getByText('Provisioned'); + await user.hover(badge); + + expect( + screen.getByText('This resource has been provisioned via api and cannot be edited through the UI') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index 7a88d1e21d7..5a9deb8a6bd 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -3,6 +3,8 @@ import { ComponentPropsWithoutRef } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Alert, Badge, Tooltip } from '@grafana/ui'; +import { KnownProvenance } from '../types/knownProvenance'; + export enum ProvisionedResource { ContactPoint = 'contact point', Template = 'template', @@ -64,11 +66,17 @@ export const ProvisioningBadge = ({ */ provenance?: string; }) => { - const badge = ; + const isConvertedPrometheus = provenance === KnownProvenance.ConvertedPrometheus; + const badgeText = isConvertedPrometheus + ? t('alerting.provisioning-badge.badge.text-converted-prometheus', 'Imported') + : t('alerting.provisioning-badge.badge.text-provisioned', 'Provisioned'); + const badgeColor = isConvertedPrometheus ? 'blue' : 'purple'; + const badge = ; if (tooltip) { + const provenanceText = isConvertedPrometheus ? 'Prometheus/Mimir' : provenance; const provenanceTooltip = ( - + This resource has been provisioned via {{ provenance }} and cannot be edited through the UI ); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx new file mode 100644 index 00000000000..2879bbb57e1 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from 'test/test-utils'; + +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; + +import { ContactPointHeader } from './ContactPointHeader'; +import { ContactPointWithMetadata } from './utils'; + +setupMswServer(); + +const renderWithProvider = (component: React.ReactElement, alertmanagerSourceName?: string) => { + return render( + + {component} + + ); +}; + +describe('ContactPointHeader', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + }); + + const mockContactPoint: ContactPointWithMetadata = { + id: 'test-contact-point', + name: 'Test Contact Point', + provenance: KnownProvenance.API, + policies: [], + grafana_managed_receiver_configs: [], + }; + + it('shows Provisioned badge when contact point has file provenance via K8s annotations', () => { + const contactPointWithFile = { + ...mockContactPoint, + provenance: KnownProvenance.File, + }; + + renderWithProvider(); + + expect(screen.getByText('Provisioned')).toBeInTheDocument(); + }); + + it('shows correct badge when contact point has converted_prometheus provenance', () => { + const contactPointWithConvertedPrometheus = { + ...mockContactPoint, + provenance: KnownProvenance.ConvertedPrometheus, + }; + + renderWithProvider(); + + expect(screen.getByText('Imported')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 0fb1403cb35..6e45c1b6512 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -13,6 +13,7 @@ import { canDeleteEntity, canEditEntity, getAnnotation, + isProvisionedResource, shouldUseK8sApi, } from 'app/features/alerting/unified/utils/k8s/utils'; @@ -31,13 +32,15 @@ interface ContactPointHeaderProps { } export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeaderProps) => { - const { name, id, provisioned, policies = [] } = contactPoint; + const { name, id, provenance, policies = [] } = contactPoint; const styles = useStyles2(getStyles); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const { selectedAlertmanager } = useAlertmanager(); const usingK8sApi = shouldUseK8sApi(selectedAlertmanager!); + const isProvisioned = isProvisionedResource(provenance); + const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); @@ -70,14 +73,14 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade /** Does the current user have permissions to edit the contact point? */ const hasAbilityToEdit = usingK8sApi ? canEditEntity(contactPoint) : editAllowed; /** Can the contact point actually be edited via the UI? */ - const contactPointIsEditable = !provisioned; + const contactPointIsEditable = !isProvisioned; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be edited? */ const canEdit = editSupported && hasAbilityToEdit && contactPointIsEditable; /** Does the current user have permissions to delete the contact point? */ const hasAbilityToDelete = usingK8sApi ? canDeleteEntity(contactPoint) : deleteAllowed; /** Can the contact point actually be deleted, regardless of permissions? i.e. ensuring it isn't provisioned and isn't referenced elsewhere */ - const contactPointIsDeleteable = !provisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; + const contactPointIsDeleteable = !isProvisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be deleted? */ const canBeDeleted = deleteSupported && hasAbilityToDelete && contactPointIsDeleteable; @@ -130,7 +133,7 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade const reasonsDeleteIsDisabled = [ !hasAbilityToDelete ? cannotDeleteNoPermissions : '', - provisioned ? cannotDeleteProvisioned : '', + isProvisioned ? cannotDeleteProvisioned : '', numberOfPoliciesPreventingDeletion > 0 ? cannotDeletePolicies : '', numberOfRules ? cannotDeleteRules : '', ].filter(Boolean); @@ -209,15 +212,13 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade {referencedByRulesText} )} - {provisioned && ( - - )} + {isProvisioned && } {!isReferencedByAnything && } { }); it('should disable buttons when provisioned', async () => { - const { user } = renderWithProvider(); + const { user } = renderWithProvider( + + ); expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index 18a5bae9e28..7524d3ba37a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -50,7 +50,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -93,7 +93,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -129,7 +129,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -178,7 +178,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -243,7 +243,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, @@ -301,7 +301,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -344,7 +344,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "lotsa-emails", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -383,7 +383,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "OnCall Conctact point", "policies": [], - "provisioned": false, + "provenance": undefined, }, { "grafana_managed_receiver_configs": [ @@ -432,7 +432,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], - "provisioned": true, + "provenance": "api", }, { "grafana_managed_receiver_configs": [ @@ -497,7 +497,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, "name": "Slack with multiple channels", "policies": [], - "provisioned": false, + "provenance": undefined, }, ], "error": undefined, diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index 2ac9c04f981..b539a239bd6 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -6,10 +6,13 @@ import { disablePlugin } from 'app/features/alerting/unified/mocks/server/config import { setOnCallIntegrations } from 'app/features/alerting/unified/mocks/server/handlers/plugins/configure-plugins'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../../mockApi'; import { grantUserPermissions } from '../../mocks'; +import { setAlertmanagerConfig } from '../../mocks/server/entities/alertmanagers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { useContactPointsWithStatus } from './useContactPoints'; @@ -69,4 +72,235 @@ describe('useContactPoints', () => { expect(snapshot).toMatchSnapshot(); }); }); + + describe('Provenance handling', () => { + it('should extract provenance when provenance is "api"', async () => { + // Set up alertmanager config with a receiver that has API provenance + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'api-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'api-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'api', // This will be used by the K8s mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'api-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.API); + }); + + it('should extract provenance when provenance is "file"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'file-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-2', + name: 'file-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'file', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'file-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance when provenance is "converted_prometheus"', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'mimir-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-3', + name: 'mimir-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + provenance: 'converted_prometheus', + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'mimir-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + expect(contactPoint?.provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should map "none" provenance annotation to undefined', async () => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'none-provenance-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-4', + name: 'none-provenance-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + // No provenance field - will default to PROVENANCE_NONE in mock handler + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'none-provenance-contact-point'); + expect(contactPoint).toBeDefined(); + // The mock handler sets PROVENANCE_NONE ('none') when no provenance is found + // parseK8sReceiver converts 'none' to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + + it('should handle missing annotations gracefully', async () => { + // This test verifies that when annotations are undefined, provenance is handled correctly + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers: [ + { + name: 'no-annotations-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-5', + name: 'no-annotations-contact-point', + type: 'email', + disableResolveMessage: false, + settings: { + addresses: 'test@example.com', + }, + secureFields: {}, + }, + ], + }, + ], + }, + }; + setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, config); + + const { result } = renderHook( + () => + useContactPointsWithStatus({ + alertmanager: GRAFANA_RULES_SOURCE_NAME, + fetchPolicies: false, + fetchStatuses: false, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contactPoint = result.current.contactPoints?.find((cp) => cp.name === 'no-annotations-contact-point'); + expect(contactPoint).toBeDefined(); + // When annotations are missing, the mock handler should set provenance to undefined + expect(contactPoint?.provenance).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts index 6627d5f69d2..bf5e8e5fcdf 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.ts @@ -11,7 +11,7 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } f import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types'; import { getAPINamespace } from '../../../../../api/utils'; @@ -21,7 +21,9 @@ import { useAsync } from '../../hooks/useAsync'; import { usePluginBridge } from '../../hooks/usePluginBridge'; import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig'; import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers'; +import { KnownProvenance } from '../../types/knownProvenance'; import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { enhanceContactPointsWithMetadata } from './utils'; @@ -78,10 +80,13 @@ const useOnCallIntegrations = ({ skip }: Skippable = {}) => { type K8sReceiver = ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; const parseK8sReceiver = (item: K8sReceiver): GrafanaManagedContactPoint => { + const metadataProvenance = item.metadata.annotations?.[K8sAnnotations.Provenance]; + const provenance = metadataProvenance === KnownProvenance.None ? undefined : metadataProvenance; + return { id: item.metadata.name || item.metadata.uid || item.spec.title, name: item.spec.title, - provisioned: isK8sEntityProvisioned(item), + provenance: provenance, grafana_managed_receiver_configs: item.spec.integrations, metadata: item.metadata, }; diff --git a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts index 91739aeca61..3083b66d300 100644 --- a/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts +++ b/public/app/features/alerting/unified/components/contact-points/useNotificationTemplates.ts @@ -16,7 +16,8 @@ import { deleteNotificationTemplateAction, updateNotificationTemplateAction, } from '../../reducers/alertmanager/notificationTemplates'; -import { K8sAnnotations, PROVENANCE_NONE } from '../../utils/k8s/constants'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { K8sAnnotations } from '../../utils/k8s/constants'; import { getAnnotation, shouldUseK8sApi } from '../../utils/k8s/utils'; import { ensureDefine } from '../../utils/templates'; import { TemplateFormValues } from '../receivers/TemplateForm'; @@ -79,7 +80,7 @@ function templateGroupsToTemplates( function templateGroupToTemplate( templateGroup: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup ): NotificationTemplate { - const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? PROVENANCE_NONE; + const provenance = getAnnotation(templateGroup, K8sAnnotations.Provenance) ?? KnownProvenance.None; return { // K8s entities should always have a metadata.name property. The type is marked as optional because it's also used in other places uid: templateGroup.metadata.name ?? templateGroup.spec.title, @@ -96,8 +97,8 @@ function amConfigToTemplates(config: AlertManagerCortexConfig): NotificationTemp uid: title, title, content, - // Undefined, null or empty string should be converted to PROVENANCE_NONE - provenance: (config.template_file_provenances ?? {})[title] || PROVENANCE_NONE, + // Undefined, null or empty string should be converted to KnownProvenance.None + provenance: (config.template_file_provenances ?? {})[title] || KnownProvenance.None, missing: !templates.includes(title), })); } @@ -272,7 +273,7 @@ export function useValidateNotificationTemplate({ } interface NotificationTemplateMetadata { - isProvisioned: boolean; + provenance?: string; } export function useNotificationTemplateMetadata( @@ -280,11 +281,11 @@ export function useNotificationTemplateMetadata( ): NotificationTemplateMetadata { if (!template) { return { - isProvisioned: false, + provenance: KnownProvenance.None, }; } return { - isProvisioned: Boolean(template.provenance) && template.provenance !== PROVENANCE_NONE, + provenance: template.provenance, }; } diff --git a/public/app/features/alerting/unified/components/contact-points/utils.test.ts b/public/app/features/alerting/unified/components/contact-points/utils.test.ts index e8ded92bf74..ebf064ff6b0 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.test.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.test.ts @@ -1,8 +1,12 @@ +import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; + +import { KnownProvenance } from '../../types/knownProvenance'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from './constants'; import { ReceiverConfigWithMetadata, + enhanceContactPointsWithMetadata, getReceiverDescription, isAutoGeneratedPolicy, summarizeEmailAddresses, @@ -128,3 +132,110 @@ describe('summarizeEmailAddresses', () => { expect(summarizeEmailAddresses('foo@foo.com\n bar@bar.com ')).toBe(output); }); }); + +describe('enhanceContactPointsWithMetadata', () => { + it('should extract provenance from receiver configs when contact point has no provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.API); + }); + + it('should prefer contact point provenance over receiver config provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + provenance: KnownProvenance.File, // Provenance on contact point (from K8s) + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + provenance: KnownProvenance.API, // Different provenance on receiver config + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.File); + }); + + it('should extract provenance from first receiver config that has it', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid-1', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance on first receiver + }, + { + uid: 'test-uid-2', + name: 'test-contact-point', + type: 'slack', + settings: { recipient: '#channel' }, + secureFields: {}, + provenance: KnownProvenance.ConvertedPrometheus, // Provenance on second receiver + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBe(KnownProvenance.ConvertedPrometheus); + }); + + it('should have undefined provenance when neither contact point nor receiver configs have provenance', () => { + const contactPoint: GrafanaManagedContactPoint = { + name: 'test-contact-point', + grafana_managed_receiver_configs: [ + { + uid: 'test-uid', + name: 'test-contact-point', + type: 'email', + settings: { addresses: 'test@example.com' }, + secureFields: {}, + // No provenance + }, + ], + }; + + const enhanced = enhanceContactPointsWithMetadata({ + contactPoints: [contactPoint], + notifiers: [], + status: [], + }); + + expect(enhanced[0].provenance).toBeUndefined(); + }); +}); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index d2cc43901c1..d24cfc2b0af 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -146,9 +146,16 @@ export function enhanceContactPointsWithMetadata({ const id = getContactPointIdentifier(contactPoint); + // Extract provenance from contactPoint first; else, search in its receivers + const contactPointProvenance = + 'provenance' in contactPoint && contactPoint.provenance !== undefined + ? contactPoint.provenance + : receivers.find((receiver) => Boolean(receiver.provenance))?.provenance; + return { ...contactPoint, id, + provenance: contactPointProvenance, policies: alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? []), grafana_managed_receiver_configs: receivers.map((receiver, index) => { diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index 94e290a2087..ce0871ac2cd 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -9,7 +9,7 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta, } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { isK8sEntityProvisioned, shouldUseK8sApi, @@ -62,7 +62,7 @@ const parseAmTimeInterval: (interval: MuteTimeInterval, provenance: string) => M return { ...interval, id: interval.name, - provisioned: Boolean(provenance && provenance !== PROVENANCE_NONE), + provisioned: Boolean(provenance && provenance !== KnownProvenance.None), }; }; diff --git a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx index 448c409b673..5e85c5f575c 100644 --- a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx @@ -11,7 +11,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes'; import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes'; import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; -import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { anyOfRequestState, isError } from '../../hooks/useAsync'; import { useAlertmanager } from '../../state/AlertmanagerContext'; @@ -27,6 +27,7 @@ import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPo import { Policy } from './Policy'; import { TIMING_OPTIONS_DEFAULTS } from './timingOptions'; import { + isRouteProvisioned, useAddNotificationPolicy, useDeleteNotificationPolicy, useNotificationPolicyRoute, @@ -99,6 +100,8 @@ export const NotificationPoliciesList = () => { } return; }, [defaultPolicy]); + const routeProvenance = defaultPolicy?.provenance; + const isRootRouteProvisioned = rootRoute ? isRouteProvisioned(rootRoute) : false; // useAsync could also work but it's hard to wait until it's done in the tests // Combining with useEffect gives more predictable results because the condition is in useEffect @@ -244,7 +247,8 @@ export const NotificationPoliciesList = () => { currentRoute={defaults(rootRoute, TIMING_OPTIONS_DEFAULTS)} contactPointsState={contactPointsState.receivers} readOnly={!hasConfigurationAPI} - provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned} + provisioned={isRootRouteProvisioned} + provenance={routeProvenance} alertManagerSourceName={selectedAlertmanager} onAddPolicy={openAddModal} onEditPolicy={openEditModal} diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index a10bca42100..62f9a57ad73 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -17,6 +17,7 @@ import { import { useAlertmanagerAbilities } from '../../hooks/useAbilities'; import { mockReceiversState } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { @@ -331,6 +332,60 @@ describe('Policy', () => { const customPolicy = screen.getByTestId('am-route-container'); expect(within(customPolicy).getByTestId('matches-all')).toBeInTheDocument(); }); + + it('shows correct badge when policy has file provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + + it('shows correct badge when policy has converted_prometheus provenance', () => { + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + routes: [], + }; + + renderPolicy( + + ); + + const badge = screen.getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); }); // Doesn't matter which path the routes use, it just needs to match the initialEntries history entry to render the element diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index c4b6a0c55b7..d638273e006 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -61,6 +61,7 @@ interface PolicyComponentProps { contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; + provenance?: string; inheritedProperties?: InheritableProperties; routesMatchingFilters?: RoutesMatchingFilters; @@ -89,6 +90,7 @@ const Policy = (props: PolicyComponentProps) => { contactPointsState, readOnly = false, provisioned = false, + provenance, alertManagerSourceName, currentRoute, inheritedProperties, @@ -255,7 +257,7 @@ const Policy = (props: PolicyComponentProps) => { {/* TODO maybe we should move errors to the gutter instead? */} {errors.length > 0 && } - {provisioned && } + {provisioned && } {!isAutoGenerated && !readOnly && ( diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx index 512f8f2ac66..a57886534ff 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx @@ -1,9 +1,15 @@ import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../../openapi/routesApi.gen'; +import { KnownProvenance } from '../../types/knownProvenance'; import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { createKubernetesRoutingTreeSpec, k8sSubRouteToRoute, routeToK8sSubRoute } from './useNotificationPolicyRoute'; +import { + createKubernetesRoutingTreeSpec, + isRouteProvisioned, + k8sSubRouteToRoute, + routeToK8sSubRoute, +} from './useNotificationPolicyRoute'; test('k8sSubRouteToRoute', () => { const input: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = { @@ -115,3 +121,86 @@ test('createKubernetesRoutingTreeSpec', () => { expect(tree.metadata.name).toBe(ROOT_ROUTE_NAME); expect(tree).toMatchSnapshot(); }); + +describe('isRouteProvisioned', () => { + it('returns false when route has no provenance', () => { + const route: Route = { + receiver: 'test-receiver', + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.None, + }, + }; + + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns false when route has KnownProvenance.None at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.None, + }; + expect(isRouteProvisioned(route)).toBeFalsy(); + }); + + it('returns true when route has file provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.File, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has api provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.API, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has converted_prometheus provenance in metadata', () => { + const route: Route = { + receiver: 'test-receiver', + [ROUTES_META_SYMBOL]: { + provenance: KnownProvenance.ConvertedPrometheus, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('returns true when route has file provenance at top level', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); + + it('falls back to top-level provenance when metadata provenance is missing', () => { + const route: Route = { + receiver: 'test-receiver', + provenance: KnownProvenance.File, + [ROUTES_META_SYMBOL]: { + provenance: undefined, + }, + }; + + expect(isRouteProvisioned(route)).toBeTruthy(); + }); +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts index e6a0b7a0cc5..ca9be820463 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts @@ -22,8 +22,8 @@ import { } from '../../reducers/alertmanager/notificationPolicyRoutes'; import { FormAmRoute } from '../../types/amroutes'; import { addUniqueIdentifierToRoute } from '../../utils/amroutes'; -import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; +import { getAnnotation, isProvisionedResource, shouldUseK8sApi } from '../../utils/k8s/utils'; import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition, @@ -33,6 +33,11 @@ import { omitRouteFromRouteTree, } from '../../utils/routeTree'; +export function isRouteProvisioned(route: Route): boolean { + const provenance = route[ROUTES_META_SYMBOL]?.provenance ?? route.provenance; + return isProvisionedResource(provenance); +} + const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 }); const { @@ -82,7 +87,7 @@ const parseAmConfigRoute = memoize((route: Route): Route => { return { ...route, [ROUTES_META_SYMBOL]: { - provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE), + provenance: route.provenance, }, }; }); @@ -232,10 +237,11 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific ...route.spec.defaults, routes: route.spec.routes?.map(k8sSubRouteToRoute), [ROUTES_META_SYMBOL]: { - provisioned: isK8sEntityProvisioned(route), + provenance: getAnnotation(route, K8sAnnotations.Provenance), resourceVersion: route.metadata.resourceVersion, name: route.metadata.name, }, + provenance: getAnnotation(route, K8sAnnotations.Provenance), }; }); } diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 5a50ab55cd4..92872e56bef 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -33,6 +33,7 @@ import { AccessControlAction } from 'app/types/accessControl'; import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { EditorColumnHeader } from '../EditorColumnHeader'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; @@ -122,7 +123,8 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props) // AI feedback state const [aiGeneratedTemplate, setAiGeneratedTemplate] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(originalTemplate); + const { provenance } = useNotificationTemplateMetadata(originalTemplate); + const isProvisioned = isProvisionedResource(provenance); const originalTemplatePrefill: TemplateFormValues | undefined = originalTemplate ? { title: originalTemplate.title, content: originalTemplate.content } : undefined; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx new file mode 100644 index 00000000000..f707d1d6b79 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, within } from 'test/test-utils'; + +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; +import { KnownProvenance } from '../../types/knownProvenance'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { NotificationTemplate } from '../contact-points/useNotificationTemplates'; + +import { TemplatesTable } from './TemplatesTable'; + +const mockTemplates: Array> = [ + { + uid: 'mimir-template', + title: 'mimir-template', + content: '{{ define "mimir-template" }}Template from Mimir{{ end }}', + provenance: KnownProvenance.ConvertedPrometheus, + }, + { + uid: 'file-template', + title: 'file-template', + content: '{{ define "file-template" }}File provisioned template{{ end }}', + provenance: KnownProvenance.File, + }, + { + uid: 'api-template', + title: 'api-template', + content: '{{ define "api-template" }}API provisioned template{{ end }}', + provenance: KnownProvenance.API, + }, + { + uid: 'no-provenance-template', + title: 'no-provenance-template', + content: '{{ define "no-provenance-template" }}No provenance template{{ end }}', + provenance: KnownProvenance.None, + }, + { + uid: 'undefined-provenance-template', + title: 'undefined-provenance-template', + content: '{{ define "undefined-provenance-template" }}Undefined provenance template{{ end }}', + provenance: undefined, + }, +]; + +const renderWithProvider = (templates: Array>) => { + return render( + + + + + ); +}; + +setupMswServer(); + +describe('TemplatesTable', () => { + beforeEach(() => { + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsExternalWrite, + ]); + }); + + it('shows "Imported" badge for templates with converted_prometheus provenance', () => { + const templates = [mockTemplates[0]]; // mimir-template + renderWithProvider(templates); + + const templateRow = screen.getByRole('row', { name: /mimir-template/i }); + const badge = within(templateRow).getByText('Imported'); + expect(badge).toBeInTheDocument(); + }); + + it('shows "Provisioned" badge for templates with other provenance', () => { + // api and file templates + [mockTemplates[1], mockTemplates[2]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + const badge = within(templateRow).getByText('Provisioned'); + expect(badge).toBeInTheDocument(); + }); + }); + + it('does not show badge for templates with KnownProvenance.None or empty string provenance', () => { + // no-provenance-template and undefined-provenance-template + [mockTemplates[3], mockTemplates[4]].forEach((template) => { + renderWithProvider([template]); + + const templateRow = screen.getByRole('row', { name: new RegExp(template.title ?? '', 'i') }); + expect(within(templateRow).queryByText('Provisioned')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx index ea00e22b280..4f71904dd73 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx @@ -10,6 +10,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d import { Authorize } from '../../components/Authorize'; import { AlertmanagerAction } from '../../hooks/useAbilities'; import { getAlertTableStyles } from '../../styles/table'; +import { isProvisionedResource } from '../../utils/k8s/utils'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { CollapseToggle } from '../CollapseToggle'; import { DetailsField } from '../DetailsField'; @@ -128,7 +129,8 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic const isGrafanaAlertmanager = alertManagerName === GRAFANA_RULES_SOURCE_NAME; const [isExpanded, setIsExpanded] = useState(false); - const { isProvisioned } = useNotificationTemplateMetadata(notificationTemplate); + const { provenance } = useNotificationTemplateMetadata(notificationTemplate); + const isProvisioned = isProvisionedResource(provenance); const { uid, title: name, content: template, missing } = notificationTemplate; const misconfiguredBadgeText = t('alerting.templates.misconfigured-badge-text', 'Misconfigured'); @@ -139,7 +141,7 @@ function TemplateRow({ notificationTemplate, idx, alertManagerName, onDeleteClic setIsExpanded(!isExpanded)} /> - {name} {isProvisioned && }{' '} + {name} {isProvisioned && }{' '} {missing && !isGrafanaAlertmanager && ( )} - {contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( - - )} - {contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( + {isProvisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && } + {isProvisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && ( )} diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx index 6134ace8fbb..866f489e6fa 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.test.tsx @@ -7,8 +7,8 @@ import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants'; import { AccessControlAction } from 'app/types/accessControl'; @@ -68,7 +68,7 @@ describe('getTemplateOptions function', () => { uid: title, title, content, - provenance: PROVENANCE_NONE, + provenance: KnownProvenance.None, }; }); const defaultTemplates = parseTemplates(DEFAULT_TEMPLATES); diff --git a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts index 9babb1332bf..1005267badf 100644 --- a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts +++ b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts @@ -4,7 +4,8 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, } from 'app/features/alerting/unified/openapi/routesApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants'; import { AlertManagerCortexConfig, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; /** @@ -66,7 +67,7 @@ export const getUserDefinedRoutingTree: ( name: ROOT_ROUTE_NAME, namespace: 'default', annotations: { - [K8sAnnotations.Provenance]: PROVENANCE_NONE, + [K8sAnnotations.Provenance]: KnownProvenance.None, }, // Resource versions are much shorter than this in reality, but this is an easy way // for us to mock the concurrency logic and check if the policies have updated since the last fetch diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index a620fe528c2..177b5c23499 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -6,8 +6,9 @@ import { } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } from 'app/features/alerting/unified/openapi/receiversApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; const usedByPolicies = ['grafana-default-email']; const usedByRules = ['grafana-default-email']; @@ -23,7 +24,7 @@ const getReceiversList = () => { const provenance = contactPoint.grafana_managed_receiver_configs?.find((integration) => { return integration.provenance; - })?.provenance || PROVENANCE_NONE; + })?.provenance || KnownProvenance.None; return { metadata: { // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts index f3baa318342..fff0d120629 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s.ts @@ -3,8 +3,9 @@ import { HttpResponse, http } from 'msw'; import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TemplateGroup } from 'app/features/alerting/unified/openapi/templatesApi.gen'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { PROVENANCE_ANNOTATION } from 'app/features/alerting/unified/utils/k8s/constants'; const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME); @@ -14,7 +15,7 @@ const mappedTemplates = Object.entries( ).map(([title, template]) => ({ metadata: { name: titleToK8sResourceName(title), // K8s uses unique identifiers for resources - annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || PROVENANCE_NONE }, + annotations: { [PROVENANCE_ANNOTATION]: config.template_file_provenances?.[title] || KnownProvenance.None }, }, spec: { title: title, diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts index 84503c2ce13..77f6eef9f50 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts @@ -4,7 +4,8 @@ import { base64UrlEncode } from '@grafana/alerting'; import { filterBySelector } from 'app/features/alerting/unified/mocks/server/handlers/k8s/utils'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; /** UID of a time interval that we expect to follow all happy paths within tests/mocks */ export const TIME_INTERVAL_UID_HAPPY_PATH = 'f4eae7a4895fa786'; @@ -21,7 +22,7 @@ const allTimeIntervals = getK8sResponse { it('should escape backslashes', () => { @@ -25,3 +27,29 @@ describe('encodeFieldSelector', () => { expect(encodeFieldSelector('foo=bar,bar=baz,qux\\foo')).toBe('foo\\=bar\\,bar\\=baz\\,qux\\\\foo'); }); }); + +describe('isProvisionedResource', () => { + it('should return true when provenance is API', () => { + expect(isProvisionedResource(KnownProvenance.API)).toBe(true); + }); + + it('should return true when provenance is File', () => { + expect(isProvisionedResource(KnownProvenance.File)).toBe(true); + }); + + it('should return true when provenance is ConvertedPrometheus', () => { + expect(isProvisionedResource(KnownProvenance.ConvertedPrometheus)).toBe(true); + }); + + it('should return false when provenance is none', () => { + expect(isProvisionedResource(KnownProvenance.None)).toBe(false); + }); + + it('should return false when provenance is undefined', () => { + expect(isProvisionedResource(undefined)).toBe(false); + }); + + it('should return true for any other non-empty string', () => { + expect(isProvisionedResource('custom-provenance')).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 48ec5685a69..015ba8f17a2 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -1,6 +1,8 @@ import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; + +import { KnownProvenance } from '../../types/knownProvenance'; /** * Should we call the kubernetes-style API for managing alertmanager entities? @@ -22,7 +24,7 @@ type EntityToCheck = { */ export const isK8sEntityProvisioned = (k8sEntity: EntityToCheck) => { const provenance = getAnnotation(k8sEntity, K8sAnnotations.Provenance); - return Boolean(provenance && provenance !== PROVENANCE_NONE); + return isProvisionedResource(provenance); }; export const ANNOTATION_PREFIX_ACCESS = 'grafana.com/access/'; @@ -59,3 +61,7 @@ export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string .map(([key, value, operator = '=']) => `${key}${operator}${encodeFieldSelector(value)}`) .join(','); }; + +export function isProvisionedResource(provenance?: string): boolean { + return Boolean(provenance && provenance !== KnownProvenance.None); +} diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 3fb512c88c6..1f964e6b664 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -108,7 +108,7 @@ export interface GrafanaManagedContactPoint { /** If parsed from k8s API, we'll have an ID property */ id?: string; metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta; - provisioned?: boolean; + provenance?: string; grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; } @@ -148,7 +148,7 @@ export type Route = { provenance?: string; /** this is used to add additional metadata to the routes without interfering with original route definition (symbols aren't iterable) */ [ROUTES_META_SYMBOL]?: { - provisioned?: boolean; + provenance?: string; resourceVersion?: string; name?: string; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0725626911c..5bf71d4e7f8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2184,6 +2184,7 @@ }, "provisioning-badge": { "badge": { + "text-converted-prometheus": "Imported", "text-provisioned": "Provisioned" } },