diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index ae093f35b48..05f84437288 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -8,7 +8,7 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { GrafanaRuleInspector } from './components/rule-editor/GrafanaRuleInspector'; +import { GrafanaRuleExporter } from './components/export/GrafanaRuleExporter'; import { AlertingFeature } from './features'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; @@ -31,7 +31,7 @@ const RuleViewer = (props: RuleViewerProps): JSX.Element => { sourceName === GRAFANA_RULES_SOURCE_NAME ? ( ) : null; @@ -39,7 +39,7 @@ const RuleViewer = (props: RuleViewerProps): JSX.Element => { return ( - {showYaml && setShowYaml(false)} />} + {showYaml && setShowYaml(false)} />} diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index e0bc634dbb1..b7a5cc98467 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -11,6 +11,7 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; +import { RuleExportFormats } 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'; @@ -30,6 +31,7 @@ export type ResponseLabels = { }; export type PreviewResponse = ResponseLabels[]; + export interface Datasource { type: string; uid: string; @@ -49,6 +51,7 @@ export interface Data { datasourceUid: string; model: AlertQuery; } + export interface GrafanaAlert { data?: Data; condition: string; @@ -62,6 +65,7 @@ export interface Rule { labels: Labels; annotations: Annotations; } + export type AlertInstances = Record; export const alertRuleApi = alertingApi.injectEndpoints({ @@ -178,8 +182,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }, }), - exportRule: build.query({ - query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format) }), + exportRule: build.query({ + query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format), responseType: 'text' }), + }), + exportRuleGroup: build.query({ + query: ({ folderUid, groupName, format }) => ({ + url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/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 new file mode 100644 index 00000000000..bdb53cd29bb --- /dev/null +++ b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/css'; +import saveAs from 'file-saver'; +import React, { useCallback, useMemo } from 'react'; +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'; + +interface FileExportPreviewProps { + format: RuleExportFormats; + textDefinition: string; + + /*** Filename without extension ***/ + downloadFileName: string; + onClose: () => void; +} + +export function FileExportPreview({ format, textDefinition, downloadFileName, onClose }: FileExportPreviewProps) { + const styles = useStyles2(fileExportPreviewStyles); + + const onDownload = useCallback(() => { + const blob = new Blob([textDefinition], { + type: `application/${format};charset=utf-8`, + }); + saveAs(blob, `${downloadFileName}.${format}`); + + onClose(); + }, [textDefinition, downloadFileName, format, onClose]); + + const formattedTextDefinition = useMemo(() => { + const provider = grafanaRuleExportProviders[format]; + return provider.formatter ? provider.formatter(textDefinition) : textDefinition; + }, [format, textDefinition]); + + return ( + // TODO Handle empty content +
+
+ + {({ height }) => ( + + )} + +
+
+ + textDefinition}> + Copy code + + +
+
+ ); +} + +const fileExportPreviewStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + flex-direction: column; + height: 100%; + gap: ${theme.spacing(2)}; + `, + content: css` + flex: 1 1 100%; + `, + actions: css` + flex: 0; + justify-content: flex-end; + display: flex; + gap: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx new file mode 100644 index 00000000000..cb05181cf63 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +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, +})); + +interface GrafanaExportDrawerProps { + activeTab: RuleExportFormats; + onTabChange: (tab: RuleExportFormats) => void; + children: React.ReactNode; + onClose: () => void; +} + +export function GrafanaExportDrawer({ activeTab, onTabChange, children, onClose }: GrafanaExportDrawerProps) { + return ( + + tabs={grafanaRulesTabs} + setActiveTab={onTabChange} + activeTab={activeTab} + /> + } + onClose={onClose} + size="md" + > + {children} + + ); +} diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx new file mode 100644 index 00000000000..095df21bff3 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; + +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 ( + + + + ); +}; + +interface GrafanaRuleExportPreviewProps { + alertUid: string; + exportFormat: RuleExportFormats; + onClose: () => void; +} + +const GrafanaRuleExportPreview = ({ alertUid, exportFormat, onClose }: GrafanaRuleExportPreviewProps) => { + const { currentData: ruleTextDefinition = '', isFetching } = alertRuleApi.useExportRuleQuery({ + uid: alertUid, + format: exportFormat, + }); + + const downloadFileName = `${alertUid}-${new Date().getTime()}`; + + if (isFetching) { + return ; + } + + return ( + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx new file mode 100644 index 00000000000..9594ab5b890 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; + +import { alertRuleApi } from '../../api/alertRuleApi'; + +import { FileExportPreview } from './FileExportPreview'; +import { GrafanaExportDrawer } from './GrafanaExportDrawer'; +import { RuleExportFormats } from './providers'; + +interface GrafanaRuleGroupExporterProps { + folderUid: string; + groupName: string; + onClose: () => void; +} + +export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: GrafanaRuleGroupExporterProps) { + const [activeTab, setActiveTab] = useState('yaml'); + + return ( + + + + ); +} + +interface GrafanaRuleGroupExportPreviewProps { + folderUid: string; + groupName: string; + exportFormat: RuleExportFormats; + onClose: () => void; +} + +function GrafanaRuleGroupExportPreview({ + folderUid, + groupName, + exportFormat, + onClose, +}: GrafanaRuleGroupExportPreviewProps) { + const { currentData: ruleGroupTextDefinition = '', isFetching } = alertRuleApi.useExportRuleGroupQuery({ + folderUid, + groupName, + format: exportFormat, + }); + + if (isFetching) { + return ; + } + + return ( + + ); +} diff --git a/public/app/features/alerting/unified/components/export/providers.ts b/public/app/features/alerting/unified/components/export/providers.ts new file mode 100644 index 00000000000..72aadc9a9dc --- /dev/null +++ b/public/app/features/alerting/unified/components/export/providers.ts @@ -0,0 +1,36 @@ +interface RuleExportProvider { + name: string; + exportFormat: TFormat; + formatter?: (raw: string) => string; +} + +const JsonRuleExportProvider: RuleExportProvider<'json'> = { + name: 'JSON', + exportFormat: 'json', + formatter: (raw: string) => { + try { + return JSON.stringify(JSON.parse(raw), null, 4); + } catch (e) { + return raw; + } + }, +}; + +const YamlRuleExportProvider: RuleExportProvider<'yaml'> = { + name: 'YAML', + exportFormat: 'yaml', +}; + +// TODO Waiting for BE changes +// const HclRuleExportProvider: RuleExportProvider<'hcl'> = { +// name: 'HCL', +// exportFormat: 'hcl', +// }; + +export const grafanaRuleExportProviders = { + [JsonRuleExportProvider.exportFormat]: JsonRuleExportProvider, + [YamlRuleExportProvider.exportFormat]: YamlRuleExportProvider, + // [HclRuleExportProvider.exportFormat]: HclRuleExportProvider, +} as const; + +export type RuleExportFormats = keyof typeof grafanaRuleExportProviders; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index d9ec536ee66..5c94362453e 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -40,11 +40,11 @@ import { rulerRuleToFormValues, } from '../../utils/rule-form'; import * as ruleId from '../../utils/rule-id'; +import { GrafanaRuleExporter } from '../export/GrafanaRuleExporter'; import AnnotationsStep from './AnnotationsStep'; import { CloudEvaluationBehavior } from './CloudEvaluationBehavior'; import { GrafanaEvaluationBehavior } from './GrafanaEvaluationBehavior'; -import { GrafanaRuleInspector } from './GrafanaRuleInspector'; import { NotificationsStep } from './NotificationsStep'; import { RecordingRulesNameSpaceAndGroupStep } from './RecordingRulesNameSpaceAndGroupStep'; import { RuleEditorSection } from './RuleEditorSection'; @@ -259,7 +259,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { disabled={submitState.loading} size="sm" > - {isCortexLokiOrRecordingRule(watch) ? 'Edit YAML' : 'View YAML'} + {isCortexLokiOrRecordingRule(watch) ? 'Edit YAML' : 'Export'} ) : null} @@ -316,7 +316,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { ) : null} {showEditYaml ? ( type === RuleFormType.grafana ? ( - setShowEditYaml(false)} /> + setShowEditYaml(false)} /> ) : ( setShowEditYaml(false)} /> ) diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaRuleInspector.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaRuleInspector.tsx deleted file mode 100644 index 97bf7b216fc..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaRuleInspector.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; - -import { CodeEditor, Drawer, useStyles2 } from '@grafana/ui'; - -import { alertRuleApi } from '../../api/alertRuleApi'; - -import { drawerStyles, RuleInspectorSubtitle, yamlTabStyle } from './RuleInspector'; - -interface Props { - onClose: () => void; - alertUid: string; -} - -export const GrafanaRuleInspector = ({ onClose, alertUid }: Props) => { - const [activeTab, setActiveTab] = useState('yaml'); - - const styles = useStyles2(drawerStyles); - - return ( - - - - } - onClose={onClose} - > - {activeTab === 'yaml' && } - - ); -}; - -const { useExportRuleQuery } = alertRuleApi; - -interface YamlTabProps { - alertUid: string; -} - -const GrafanaInspectorYamlTab = ({ alertUid }: YamlTabProps) => { - const styles = useStyles2(yamlTabStyle); - - const { currentData: ruleYamlConfig, isLoading } = useExportRuleQuery({ uid: alertUid, format: 'yaml' }); - - const yamlRule = useMemo(() => ruleYamlConfig, [ruleYamlConfig]); - - if (isLoading) { - return
Loading...
; - } - - return ( - <> -
- - {({ height }) => ( - - )} - -
- - ); -}; diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx index 1d56df6111f..83690f08db5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx @@ -20,7 +20,7 @@ interface Props { onClose: () => void; } -const tabs = [{ label: 'Yaml', value: 'yaml' }]; +const cloudRulesTabs = [{ label: 'Yaml', value: 'yaml' }]; export const RuleInspector = ({ onClose }: Props) => { const [activeTab, setActiveTab] = useState('yaml'); @@ -42,7 +42,7 @@ export const RuleInspector = ({ onClose }: Props) => { title="Inspect Alert rule" subtitle={
- +
} onClose={onClose} @@ -52,12 +52,13 @@ export const RuleInspector = ({ onClose }: Props) => { ); }; -interface SubtitleProps { - activeTab: string; - setActiveTab: (tab: string) => void; +interface RuleInspectorTabsProps { + tabs: Array<{ label: string; value: T }>; + activeTab: T; + setActiveTab: (tab: T) => void; } -export const RuleInspectorSubtitle = ({ activeTab, setActiveTab }: SubtitleProps) => { +export function RuleInspectorTabs({ tabs, activeTab, setActiveTab }: RuleInspectorTabsProps) { return ( {tabs.map((tab, index) => { @@ -73,7 +74,7 @@ export const RuleInspectorSubtitle = ({ activeTab, setActiveTab }: SubtitleProps })} ); -}; +} interface YamlTabProps { onSubmit: (newModel: RuleFormValues) => void; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx index b5eb6ca1452..3e6f8c88005 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx @@ -57,7 +57,7 @@ const mocks = { const ui = { actionButtons: { edit: byRole('link', { name: /edit/i }), - clone: byRole('link', { name: /copy/i }), + clone: byRole('button', { name: /^copy$/i }), delete: byRole('button', { name: /delete/i }), silence: byRole('link', { name: 'Silence' }), }, diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx index ffd7cf71183..27ef3e827d0 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx @@ -255,7 +255,7 @@ function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) { return ( - {rule.uid} + {rule.uid} ); } diff --git a/public/app/features/alerting/unified/components/rules/CloneRule.tsx b/public/app/features/alerting/unified/components/rules/CloneRule.tsx new file mode 100644 index 00000000000..4ea1bf6d54f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/CloneRule.tsx @@ -0,0 +1,95 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { Redirect } from 'react-router-dom'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ConfirmModal, useStyles2 } from '@grafana/ui'; +import { RuleIdentifier } from 'app/types/unified-alerting'; + +import * as ruleId from '../../utils/rule-id'; + +interface ConfirmCloneRuleModalProps { + identifier: RuleIdentifier; + isProvisioned: boolean; + onDismiss: () => void; +} + +export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: ConfirmCloneRuleModalProps) { + const styles = useStyles2(getStyles); + + // For provisioned rules an additional confirmation step is required + // Users have to be aware that the cloned rule will NOT be marked as provisioned + const [stage, setStage] = useState<'redirect' | 'confirm'>(isProvisioned ? 'confirm' : 'redirect'); + + if (stage === 'redirect') { + const cloneUrl = `/alerting/new?copyFrom=${ruleId.stringifyIdentifier(identifier)}`; + return ; + } + + return ( + +

+ The new rule will NOT be marked as a provisioned rule. +

+

+ You will need to set a new evaluation group for the copied rule because the original one has been + provisioned and cannot be used for rules created in the UI. +

+ + } + confirmText="Copy" + onConfirm={() => setStage('redirect')} + onDismiss={onDismiss} + /> + ); +} + +interface CloneRuleButtonProps { + ruleIdentifier: RuleIdentifier; + isProvisioned: boolean; + text?: string; + className?: string; +} + +export const CloneRuleButton = React.forwardRef( + ({ text, ruleIdentifier, isProvisioned, className }, ref) => { + const [redirectToClone, setRedirectToClone] = useState(false); + + return ( + <> + + + {redirectToClone && ( + setRedirectToClone(false)} + /> + )} + + ); + } +); + +CloneRuleButton.displayName = 'CloneRuleButton'; + +const getStyles = (theme: GrafanaTheme2) => ({ + bold: css` + font-weight: ${theme.typography.fontWeightBold}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx b/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx deleted file mode 100644 index 1175fa966af..00000000000 --- a/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { ConfirmModal, LinkButton, useStyles2 } from '@grafana/ui'; -import { RuleIdentifier } from 'app/types/unified-alerting'; - -import * as ruleId from '../../utils/rule-id'; - -interface CloneRuleButtonProps { - ruleIdentifier: RuleIdentifier; - isProvisioned: boolean; - text?: string; - className?: string; -} - -export const CloneRuleButton = React.forwardRef( - ({ text, ruleIdentifier, isProvisioned, className }, ref) => { - // For provisioned rules an additional confirmation step is required - // Users have to be aware that the cloned rule will NOT be marked as provisioned - const [showModal, setShowModal] = useState(false); - - const styles = useStyles2(getStyles); - const cloneUrl = '/alerting/new?copyFrom=' + ruleId.stringifyIdentifier(ruleIdentifier); - - return ( - <> - setShowModal(true) : undefined} - ref={ref} - > - {text} - - - -

- The new rule will NOT be marked as a provisioned rule. -

-

- You will need to set a new alert group for the copied rule because the original one has been provisioned - and cannot be used for rules created in the UI. -

- - } - confirmText="Copy" - onConfirm={() => { - locationService.push(cloneUrl); - }} - onDismiss={() => setShowModal(false)} - /> - - ); - } -); - -CloneRuleButton.displayName = 'CloneRuleButton'; - -const getStyles = (theme: GrafanaTheme2) => ({ - bold: css` - font-weight: ${theme.typography.fontWeightBold}; - `, -}); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 3044a988851..1867dfc929f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -1,23 +1,39 @@ import { css } from '@emotion/css'; +import { uniqueId } from 'lodash'; import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Button, ClipboardButton, ConfirmModal, LinkButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Button, + ClipboardButton, + ConfirmModal, + Dropdown, + Icon, + LinkButton, + Menu, + Tooltip, + useStyles2, +} from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useDispatch } from 'app/types'; -import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; +import { contextSrv } from '../../../../../core/services/context_srv'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { deleteRuleAction } from '../../state/actions'; +import { provisioningPermissions } from '../../utils/access-control'; import { getRulesSourceName } from '../../utils/datasource'; import { createShareLink, createViewLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { createUrl } from '../../utils/url'; +import { GrafanaRuleExporter } from '../export/GrafanaRuleExporter'; + +import { RedirectToCloneRule } from './CloneRule'; -import { CloneRuleButton } from './CloneRuleButton'; export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches; interface Props { @@ -30,14 +46,22 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => { const location = useLocation(); const notifyApp = useAppNotification(); const style = useStyles2(getStyles); + + const [redirectToClone, setRedirectToClone] = useState< + { identifier: RuleIdentifier; isProvisioned: boolean } | undefined + >(undefined); + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); + const { namespace, group, rulerRule } = rule; const [ruleToDelete, setRuleToDelete] = useState(); const rulesSourceName = getRulesSourceName(rulesSource); + const canReadProvisioning = contextSrv.hasPermission(provisioningPermissions.read); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const buttons: JSX.Element[] = []; + const moreActions: JSX.Element[] = []; const isFederated = isFederatedRuleGroup(group); const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule); @@ -118,28 +142,17 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => { ); } - buttons.push( - - - + if (isGrafanaRulerRule(rulerRule) && canReadProvisioning) { + moreActions.push(); + } + + moreActions.push( + setRedirectToClone({ identifier, isProvisioned })} /> ); } if (isRemovable && rulerRule && !isFederated && !isProvisioned) { - buttons.push( - - + {!!ruleToDelete && ( { onDismiss={() => setRuleToDelete(undefined)} /> )} + {showExportDrawer && isGrafanaRulerRule(rule.rulerRule) && ( + + )} + {redirectToClone && ( + setRedirectToClone(undefined)} + /> + )} ); } diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 959c41dea8a..ac39d0be8bc 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -26,7 +26,7 @@ import * as ruleId from '../../utils/rule-id'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { DeclareIncident } from '../bridges/DeclareIncidentButton'; -import { CloneRuleButton } from './CloneRuleButton'; +import { CloneRuleButton } from './CloneRule'; interface Props { rule: CombinedRule; diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx index c7032e7c52a..44cee9f6f9f 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { byTestId, byText } from 'testing-library-selector'; +import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { byRole, byTestId, byText } from 'testing-library-selector'; import { logInfo } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -11,7 +12,8 @@ import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-aler import { LogMessages } from '../../Analytics'; import { useHasRuler } from '../../hooks/useHasRuler'; -import { disableRBAC, mockCombinedRule, mockDataSource } from '../../mocks'; +import { mockFolderApi, mockProvisioningApi, setupMswServer } from '../../mockApi'; +import { disableRBAC, mockCombinedRule, mockDataSource, mockFolder, mockGrafanaRulerRule } from '../../mocks'; import { RulesGroup } from './RulesGroup'; @@ -23,6 +25,14 @@ jest.mock('@grafana/runtime', () => { logInfo: jest.fn(), }; }); +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 }); +}); +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + CodeEditor: ({ value }: { value: string }) =>