diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index cd0983c7eb6..b3f0d7f6a25 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -11,7 +11,7 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; -import { RuleExportFormats } from '../components/export/providers'; +import { ExportFormats } from '../components/export/providers'; import { Folder } from '../components/rule-editor/RuleFolderPicker'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { arrayKeyValuesToObject } from '../utils/labels'; @@ -40,7 +40,7 @@ export interface Datasource { export const PREVIEW_URL = '/api/v1/rule/test/grafana'; export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules'; -function getProvisioningUrl(ruleUid: string, format: RuleExportFormats = 'yaml') { +function getProvisioningExportUrl(ruleUid: string, format: 'yaml' | 'json' | 'hcl' = 'yaml') { return `/api/v1/provisioning/alert-rules/${ruleUid}/export?format=${format}`; } @@ -182,15 +182,36 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }, }), - exportRule: build.query({ - query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format), responseType: 'text' }), + exportRule: build.query({ + query: ({ uid, format }) => ({ url: getProvisioningExportUrl(uid, format), responseType: 'text' }), }), - exportRuleGroup: build.query({ + exportRuleGroup: build.query({ query: ({ folderUid, groupName, format }) => ({ url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`, params: { format: format }, responseType: 'text', }), }), + exportReceiver: build.query({ + query: ({ receiverName, decrypt, format }) => ({ + url: `/api/v1/provisioning/contact-points/export/`, + params: { format: format, decrypt: decrypt, name: receiverName }, + responseType: 'text', + }), + }), + exportReceivers: build.query({ + query: ({ decrypt, format }) => ({ + url: `/api/v1/provisioning/contact-points/export/`, + params: { format: format, decrypt: decrypt }, + responseType: 'text', + }), + }), + exportPolicies: build.query({ + query: ({ format }) => ({ + url: `/api/v1/provisioning/policies/export/`, + params: { format: format }, + responseType: 'text', + }), + }), }), }); diff --git a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx index bdb53cd29bb..f9729cc4a3a 100644 --- a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx +++ b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx @@ -6,10 +6,10 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, ClipboardButton, CodeEditor, useStyles2 } from '@grafana/ui'; -import { grafanaRuleExportProviders, RuleExportFormats } from './providers'; +import { allGrafanaExportProviders, ExportFormats } from './providers'; interface FileExportPreviewProps { - format: RuleExportFormats; + format: ExportFormats; textDefinition: string; /*** Filename without extension ***/ @@ -30,7 +30,7 @@ export function FileExportPreview({ format, textDefinition, downloadFileName, on }, [textDefinition, downloadFileName, format, onClose]); const formattedTextDefinition = useMemo(() => { - const provider = grafanaRuleExportProviders[format]; + const provider = allGrafanaExportProviders[format]; return provider.formatter ? provider.formatter(textDefinition) : textDefinition; }, [format, textDefinition]); diff --git a/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx index cb05181cf63..01201433084 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx @@ -4,31 +4,34 @@ import { Drawer } from '@grafana/ui'; import { RuleInspectorTabs } from '../rule-editor/RuleInspector'; -import { grafanaRuleExportProviders, RuleExportFormats } from './providers'; - -const grafanaRulesTabs = Object.values(grafanaRuleExportProviders).map((provider) => ({ - label: provider.name, - value: provider.exportFormat, -})); +import { ExportFormats, ExportProvider } from './providers'; interface GrafanaExportDrawerProps { - activeTab: RuleExportFormats; - onTabChange: (tab: RuleExportFormats) => void; + activeTab: ExportFormats; + onTabChange: (tab: ExportFormats) => void; children: React.ReactNode; onClose: () => void; + formatProviders: Array>; } -export function GrafanaExportDrawer({ activeTab, onTabChange, children, onClose }: GrafanaExportDrawerProps) { +export function GrafanaExportDrawer({ + activeTab, + onTabChange, + children, + onClose, + formatProviders, +}: GrafanaExportDrawerProps) { + const grafanaRulesTabs = Object.values(formatProviders).map((provider) => ({ + label: provider.name, + value: provider.exportFormat, + })); + return ( - tabs={grafanaRulesTabs} - setActiveTab={onTabChange} - activeTab={activeTab} - /> + tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} /> } onClose={onClose} size="md" diff --git a/public/app/features/alerting/unified/components/export/GrafanaPoliciesExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaPoliciesExporter.tsx new file mode 100644 index 00000000000..7cf5d82dd35 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaPoliciesExporter.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; + +import { alertRuleApi } from '../../api/alertRuleApi'; + +import { FileExportPreview } from './FileExportPreview'; +import { GrafanaExportDrawer } from './GrafanaExportDrawer'; +import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers'; +interface GrafanaPoliciesPreviewProps { + exportFormat: ExportFormats; + onClose: () => void; +} + +const GrafanaPoliciesExporterPreview = ({ exportFormat, onClose }: GrafanaPoliciesPreviewProps) => { + const { currentData: policiesDefinition = '', isFetching } = alertRuleApi.useExportPoliciesQuery({ + format: exportFormat, + }); + + const downloadFileName = `policies-${new Date().getTime()}`; + + if (isFetching) { + return ; + } + + return ( + + ); +}; + +interface GrafanaPoliciesExporterProps { + onClose: () => void; +} + +export const GrafanaPoliciesExporter = ({ onClose }: GrafanaPoliciesExporterProps) => { + const [activeTab, setActiveTab] = useState('yaml'); + + return ( + + + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaReceiverExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaReceiverExporter.tsx new file mode 100644 index 00000000000..ddf4380ac92 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaReceiverExporter.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; + +import { alertRuleApi } from '../../api/alertRuleApi'; + +import { FileExportPreview } from './FileExportPreview'; +import { GrafanaExportDrawer } from './GrafanaExportDrawer'; +import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers'; + +interface GrafanaReceiverExportPreviewProps { + exportFormat: ExportFormats; + onClose: () => void; + receiverName: string; + decrypt: boolean; +} + +const GrafanaReceiverExportPreview = ({ + receiverName, + decrypt, + exportFormat, + onClose, +}: GrafanaReceiverExportPreviewProps) => { + const { currentData: receiverDefinition = '', isFetching } = alertRuleApi.useExportReceiverQuery({ + receiverName: receiverName, + decrypt: decrypt, + format: exportFormat, + }); + + const downloadFileName = `cp-${receiverName}-${new Date().getTime()}`; + + if (isFetching) { + return ; + } + + return ( + + ); +}; + +interface GrafanaReceiverExporterProps { + onClose: () => void; + receiverName: string; + decrypt: boolean; +} + +export const GrafanaReceiverExporter = ({ onClose, receiverName, decrypt }: GrafanaReceiverExporterProps) => { + const [activeTab, setActiveTab] = useState('yaml'); + + return ( + + + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaReceiversExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaReceiversExporter.tsx new file mode 100644 index 00000000000..b843d5a1be4 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaReceiversExporter.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; + +import { alertRuleApi } from '../../api/alertRuleApi'; + +import { FileExportPreview } from './FileExportPreview'; +import { GrafanaExportDrawer } from './GrafanaExportDrawer'; +import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers'; + +interface GrafanaReceiversExportPreviewProps { + exportFormat: ExportFormats; + onClose: () => void; + decrypt: boolean; +} + +const GrafanaReceiversExportPreview = ({ decrypt, exportFormat, onClose }: GrafanaReceiversExportPreviewProps) => { + const { currentData: receiverDefinition = '', isFetching } = alertRuleApi.useExportReceiversQuery({ + decrypt: decrypt, + format: exportFormat, + }); + + const downloadFileName = `contact-points-${new Date().getTime()}`; + + if (isFetching) { + return ; + } + + return ( + + ); +}; + +interface GrafanaReceiversExporterProps { + onClose: () => void; + decrypt: boolean; +} + +export const GrafanaReceiversExporter = ({ onClose, decrypt }: GrafanaReceiversExporterProps) => { + const [activeTab, setActiveTab] = useState('yaml'); + + return ( + + + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx index 095df21bff3..852eb4914ec 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx @@ -6,26 +6,11 @@ import { alertRuleApi } from '../../api/alertRuleApi'; import { FileExportPreview } from './FileExportPreview'; import { GrafanaExportDrawer } from './GrafanaExportDrawer'; -import { RuleExportFormats } from './providers'; - -interface GrafanaRuleExporterProps { - onClose: () => void; - alertUid: string; -} - -export const GrafanaRuleExporter = ({ onClose, alertUid }: GrafanaRuleExporterProps) => { - const [activeTab, setActiveTab] = useState('yaml'); - - return ( - - - - ); -}; +import { allGrafanaExportProviders, ExportFormats } from './providers'; interface GrafanaRuleExportPreviewProps { alertUid: string; - exportFormat: RuleExportFormats; + exportFormat: ExportFormats; onClose: () => void; } @@ -50,3 +35,23 @@ const GrafanaRuleExportPreview = ({ alertUid, exportFormat, onClose }: GrafanaRu /> ); }; + +interface GrafanaRulerExporterProps { + onClose: () => void; + alertUid: string; +} + +export const GrafanaRuleExporter = ({ onClose, alertUid }: GrafanaRulerExporterProps) => { + const [activeTab, setActiveTab] = useState('yaml'); + + return ( + + + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx index 9594ab5b890..f16c4dbc296 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx @@ -6,7 +6,7 @@ import { alertRuleApi } from '../../api/alertRuleApi'; import { FileExportPreview } from './FileExportPreview'; import { GrafanaExportDrawer } from './GrafanaExportDrawer'; -import { RuleExportFormats } from './providers'; +import { allGrafanaExportProviders, ExportFormats } from './providers'; interface GrafanaRuleGroupExporterProps { folderUid: string; @@ -15,10 +15,15 @@ interface GrafanaRuleGroupExporterProps { } export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: GrafanaRuleGroupExporterProps) { - const [activeTab, setActiveTab] = useState('yaml'); + const [activeTab, setActiveTab] = useState('yaml'); return ( - + void; } diff --git a/public/app/features/alerting/unified/components/export/providers.ts b/public/app/features/alerting/unified/components/export/providers.ts index 48b7f7ec505..356909609bb 100644 --- a/public/app/features/alerting/unified/components/export/providers.ts +++ b/public/app/features/alerting/unified/components/export/providers.ts @@ -1,10 +1,10 @@ -interface RuleExportProvider { +export interface ExportProvider { name: string; exportFormat: TFormat; formatter?: (raw: string) => string; } -const JsonRuleExportProvider: RuleExportProvider<'json'> = { +export const JsonExportProvider: ExportProvider<'json'> = { name: 'JSON', exportFormat: 'json', formatter: (raw: string) => { @@ -16,20 +16,22 @@ const JsonRuleExportProvider: RuleExportProvider<'json'> = { }, }; -const YamlRuleExportProvider: RuleExportProvider<'yaml'> = { +export const YamlExportProvider: ExportProvider<'yaml'> = { name: 'YAML', exportFormat: 'yaml', }; -const HclRuleExportProvider: RuleExportProvider<'hcl'> = { +export const HclExportProvider: ExportProvider<'hcl'> = { name: 'Terraform (HCL)', exportFormat: 'hcl', }; -export const grafanaRuleExportProviders = { - [JsonRuleExportProvider.exportFormat]: JsonRuleExportProvider, - [YamlRuleExportProvider.exportFormat]: YamlRuleExportProvider, - [HclRuleExportProvider.exportFormat]: HclRuleExportProvider, +export const allGrafanaExportProviders = { + [JsonExportProvider.exportFormat]: JsonExportProvider, + [YamlExportProvider.exportFormat]: YamlExportProvider, + [HclExportProvider.exportFormat]: HclExportProvider, } as const; -export type RuleExportFormats = keyof typeof grafanaRuleExportProviders; +export const jsonAndYamlGrafanaExportProviders = [JsonExportProvider, YamlExportProvider]; + +export type ExportFormats = keyof typeof allGrafanaExportProviders; 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 e6174e28c6e..3631d7dfa06 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 @@ -1,4 +1,4 @@ -import { screen, render, within } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash'; import React from 'react'; @@ -14,19 +14,28 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types/alerting'; +import { useAlertmanagerAbilities } from '../../hooks/useAbilities'; import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { Policy } from './Policy'; -beforeAll(() => { - userEvent.setup(); -}); +jest.mock('../../hooks/useAbilities', () => ({ + ...jest.requireActual('../../hooks/useAbilities'), + useAlertmanagerAbilities: jest.fn(), +})); + +const useAlertmanagerAbilitiesMock = jest.mocked(useAlertmanagerAbilities); describe('Policy', () => { beforeAll(() => { jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); + useAlertmanagerAbilitiesMock.mockReturnValue([ + [true, true], + [true, true], + [true, true], + ]); }); it('should render a policy tree', async () => { @@ -38,6 +47,7 @@ describe('Policy', () => { ); const routeTree = mockRoutes; + const user = userEvent.setup(); renderPolicy( { // click "more actions" and check if we can edit and delete expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument(); - await userEvent.click(within(defaultPolicy).getByTestId('more-actions')); + await user.click(within(defaultPolicy).getByTestId('more-actions')); // should be editable const editDefaultPolicy = screen.getByRole('menuitem', { name: 'Edit' }); expect(editDefaultPolicy).toBeInTheDocument(); expect(editDefaultPolicy).not.toBeDisabled(); - await userEvent.click(editDefaultPolicy); + await user.click(editDefaultPolicy); expect(onEditPolicy).toHaveBeenCalledWith(routeTree, true); // should not be deletable @@ -102,11 +112,11 @@ describe('Policy', () => { const policy = within(container); // click "more actions" and check if we can delete - await userEvent.click(policy.getByTestId('more-actions')); + await user.click(policy.getByTestId('more-actions')); expect(screen.queryByRole('menuitem', { name: 'Edit' })).not.toBeDisabled(); expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeDisabled(); - await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + await user.click(screen.getByRole('menuitem', { name: 'Delete' })); expect(onDeletePolicy).toHaveBeenCalled(); } @@ -133,6 +143,110 @@ describe('Policy', () => { ); }); + it('should show export option when export is allowed and supported returns true', async () => { + const onEditPolicy = jest.fn(); + const onAddPolicy = jest.fn(); + const onDeletePolicy = jest.fn(); + const onShowAlertInstances = jest.fn( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {} + ); + + const routeTree = mockRoutes; + const user = userEvent.setup(); + + renderPolicy( + + ); + // should have default policy + const defaultPolicy = screen.getByTestId('am-root-route-container'); + // click "more actions" + expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument(); + await user.click(within(defaultPolicy).getByTestId('more-actions')); + expect(screen.getByRole('menuitem', { name: 'Export' })).toBeInTheDocument(); + }); + + it('should not show export option when is not supported', async () => { + const onEditPolicy = jest.fn(); + const onAddPolicy = jest.fn(); + const onDeletePolicy = jest.fn(); + const onShowAlertInstances = jest.fn( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {} + ); + + const routeTree = mockRoutes; + + useAlertmanagerAbilitiesMock.mockReturnValue([ + [true, true], + [true, true], + [false, true], + ]); + + const user = userEvent.setup(); + + renderPolicy( + + ); + // should have default policy + const defaultPolicy = screen.getByTestId('am-root-route-container'); + // click "more actions" + expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument(); + await user.click(within(defaultPolicy).getByTestId('more-actions')); + expect(screen.queryByRole('menuitem', { name: 'Export' })).not.toBeInTheDocument(); + }); + + it('should not show export option when is not allowed', async () => { + const onEditPolicy = jest.fn(); + const onAddPolicy = jest.fn(); + const onDeletePolicy = jest.fn(); + const onShowAlertInstances = jest.fn( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {} + ); + + const routeTree = mockRoutes; + + useAlertmanagerAbilitiesMock.mockReturnValue([ + [true, true], + [true, true], + [true, false], + ]); + + const user = userEvent.setup(); + + renderPolicy( + + ); + // should have default policy + const defaultPolicy = screen.getByTestId('am-root-route-container'); + // click "more actions" + expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument(); + await user.click(within(defaultPolicy).getByTestId('more-actions')); + expect(screen.queryByRole('menuitem', { name: 'Export' })).not.toBeInTheDocument(); + }); + it('should not allow editing readOnly policy tree', () => { const routeTree: RouteWithID = { id: '0', routes: [{ id: '1' }] }; 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 f20be14714d..a8defc82614 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -1,14 +1,15 @@ import { css } from '@emotion/css'; -import { uniqueId, groupBy, upperFirst, sumBy, isArray, defaults } from 'lodash'; +import { defaults, groupBy, isArray, sumBy, uniqueId, upperFirst } from 'lodash'; import pluralize from 'pluralize'; import React, { FC, Fragment, ReactNode } from 'react'; import { Link } from 'react-router-dom'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Badge, Button, Dropdown, getTagColorsFromName, Icon, Menu, Tooltip, useStyles2, Text } from '@grafana/ui'; +import { Badge, Button, Dropdown, getTagColorsFromName, Icon, Menu, Text, Tooltip, useStyles2 } from '@grafana/ui'; import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; -import { RouteWithID, Receiver, ObjectMatcher, AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types'; import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities'; @@ -16,7 +17,6 @@ import { INTEGRATION_ICONS } from '../../types/contact-points'; import { normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies'; -import { createUrl } from '../../utils/url'; import { Authorize } from '../Authorize'; import { HoverCard } from '../HoverCard'; import { Label } from '../Label'; @@ -24,6 +24,7 @@ import { MetaText } from '../MetaText'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { Strong } from '../Strong'; +import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter'; import { Matchers } from './Matchers'; import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions'; @@ -125,6 +126,7 @@ const Policy: FC = ({ ? sumBy(matchingAlertGroups, (group) => group.alerts.length) : undefined; + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy; const showEditAction = updatePoliciesSupported && updatePoliciesAllowed; const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy; @@ -149,16 +151,7 @@ const Policy: FC = ({ if (showExportAction) { dropdownMenuActions.push( - + ); } @@ -221,7 +214,6 @@ const Policy: FC = ({ - {dropdownMenuActions.length > 0 && ( {dropdownMenuActions}}>