From d0df6b8de4067923f02b0bc70be464d12b25d212 Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Wed, 14 Jan 2026 08:58:03 -0500 Subject: [PATCH] Alerting: Provisioning Status Differentiation for ALL resources (#115773) * Show different badge for converted prometheus provisioned resource * Update contact points and templates to populate provenance * Update notification policies * Handle non k8s contact point in ContactPointHeader * Fix provenance check in enhanceContactPointsWithMetadata * Update translations * Fix unused import * Refactor provenance enum * Derive provisioned status from provenance in Route type * Remove unused imports * Treat PROVENANCE_NONE as no provenance in isRouteProvisione * Rename KnownProvenance.None to .Empty to avoid confusion * Change copy text for resources with converted_prometheus provenance * Derive provisioned status from provenance in GrafanaManagedContactPoint * Fix linter errors * Extract helper method to check if contact point is provisioned * Replace string literal with constant * Refactor KnownProvenance enum values Refactored the KnownProvenance enum to better reflect the known provenances defined by the backend. Also refactored the methods where we assert if a resource is provisioned to better reflect the cases for which a provenance value reflects no provisioning. A resource is considered not provisioned when the provenance is equal to '', 'none' or undefined. * Use provenance to infer provenance status for Templates Refactored useNotificationTemplateMetadata to use only provenance value, and extracted method used to assert if resource is provisioned or not to k8s/utils in order to be more resource agnostic. * Replace empty string with 'none' for KnownProvenance enum The empty string valye for provenance gets mapped to the string literal 'none' before being passed down in the api response, therefore we can use only 'none' * Replace PROVENANCE_NONE with KnownProvenance.None Replaced the constant PROVENANCE_NONE with the KnownProvenance.None enum value since the values where duplicated * Fix JSDoc * Change copy text for ProvisioningBadge * Add missing tooltip in notification policy badge * Add missing tooltip in TemplatesTable badge * fix conflicts --------- Co-authored-by: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Co-authored-by: Sonia Aguilar --- .../unified/components/Provisioning.test.tsx | 67 +++++ .../unified/components/Provisioning.tsx | 12 +- .../ContactPointHeader.test.tsx | 60 +++++ .../contact-points/ContactPointHeader.tsx | 17 +- .../contact-points/ContactPoints.test.tsx | 5 +- .../useContactPoints.test.tsx.snap | 20 +- .../contact-points/useContactPoints.test.tsx | 234 ++++++++++++++++++ .../contact-points/useContactPoints.ts | 9 +- .../useNotificationTemplates.ts | 15 +- .../components/contact-points/utils.test.ts | 111 +++++++++ .../components/contact-points/utils.ts | 7 + .../mute-timings/useMuteTimings.tsx | 4 +- .../NotificationPoliciesList.tsx | 8 +- .../notification-policies/Policy.test.tsx | 55 ++++ .../notification-policies/Policy.tsx | 4 +- .../useNotificationPolicyRoute.test.tsx | 91 ++++++- .../useNotificationPolicyRoute.ts | 14 +- .../components/receivers/TemplateForm.tsx | 4 +- .../receivers/TemplatesTable.test.tsx | 98 ++++++++ .../components/receivers/TemplatesTable.tsx | 6 +- .../receivers/form/GrafanaReceiverForm.tsx | 15 +- .../form/fields/TemplateSelector.test.tsx | 4 +- .../mocks/server/entities/k8s/routingtrees.ts | 5 +- .../server/handlers/k8s/receivers.k8s.ts | 5 +- .../server/handlers/k8s/templates.k8s.ts | 5 +- .../server/handlers/k8s/timeIntervals.k8s.ts | 5 +- .../alerting/unified/types/knownProvenance.ts | 6 + .../alerting/unified/utils/k8s/constants.ts | 3 - .../alerting/unified/utils/k8s/utils.test.ts | 30 ++- .../alerting/unified/utils/k8s/utils.ts | 10 +- .../plugins/datasource/alertmanager/types.ts | 4 +- public/locales/en-US/grafana.json | 1 + 32 files changed, 867 insertions(+), 67 deletions(-) create mode 100644 public/app/features/alerting/unified/components/Provisioning.test.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/ContactPointHeader.test.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx create mode 100644 public/app/features/alerting/unified/types/knownProvenance.ts 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" } },