From 624f44fdb57e27c473bc199624ff8b55e85a60f6 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:52:28 +0100 Subject: [PATCH] Alerting: Add new button for exporting new alert rule in HCL format (#96785) * add new button for exporting new alert rule * Fix test * allow only HCL format for exporting new alert rule * fix initial tab * Update public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx Co-authored-by: Konrad Lalik * Update public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx Co-authored-by: Konrad Lalik * address review comments * update translations --------- Co-authored-by: Konrad Lalik --- public/app/features/alerting/routes.tsx | 11 ++++ .../features/alerting/unified/Analytics.ts | 1 + .../alerting/unified/RuleList.test.tsx | 2 +- .../export/ExportNewGrafanaRule.tsx | 30 ++++++++++ .../components/export/GrafanaExportDrawer.tsx | 10 +++- .../alert-rule-form/ModifyExportRuleForm.tsx | 56 +++++++++++++------ .../getPayloadToExport.test.ts | 14 ++--- .../unified/rule-list/RuleList.v1.tsx | 31 +++++++++- public/locales/en-US/grafana.json | 7 +++ public/locales/pseudo-LOCALE/grafana.json | 7 +++ 10 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index bb7bbe9c995..08deaa8e960 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -227,6 +227,17 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) ), }, + { + path: '/alerting/export-new-rule', + pageClass: 'page-alerting', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead]), + component: importAlertingComponent( + () => + import( + /* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/ExportNewGrafanaRule' + ) + ), + }, { path: '/alerting/:sourceName/:id/view', pageClass: 'page-alerting', diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 87044454af4..676f0e09e0e 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -27,6 +27,7 @@ export const LogMessages = { unknownMessageFromError: 'unknown messageFromError', grafanaRecording: 'creating Grafana recording rule from scratch', loadedCentralAlertStateHistory: 'loaded central alert state history', + exportNewGrafanaRule: 'exporting new Grafana rule', }; const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger('features.alerting', { diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index b7fd41728b3..6fee153615b 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -120,7 +120,7 @@ const ui = { rulesFilterInput: byTestId('search-query-input'), moreErrorsButton: byRole('button', { name: /more errors/ }), editCloudGroupIcon: byTestId('edit-group'), - newRuleButton: byText(/new alert rule/i), + newRuleButton: byRole('link', { name: 'New alert rule' }), exportButton: byText(/export rules/i), editGroupModal: { dialog: byRole('dialog'), diff --git a/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx new file mode 100644 index 00000000000..2e99bc0dc41 --- /dev/null +++ b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm'; + +export default function ExportNewGrafanaRule() { + return ( + + + + ); +} + +interface ExportNewGrafanaRuleWrapperProps { + children: React.ReactNode; +} + +function ExportNewGrafanaRuleWrapper({ children }: ExportNewGrafanaRuleWrapperProps) { + return ( + + {children} + + ); +} diff --git a/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx index 660258b1888..e0a251a4f8b 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaExportDrawer.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Drawer } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { RuleInspectorTabs } from '../rule-editor/RuleInspector'; @@ -27,10 +28,17 @@ export function GrafanaExportDrawer({ label: provider.name, value: provider.exportFormat, })); + const subtitle = + formatProviders.length > 1 + ? t( + 'alerting.export.subtitle.formats', + 'Select the format and download the file or copy the contents to clipboard' + ) + : t('alerting.export.subtitle.one-format', 'Download the file or copy the contents to clipboard'); return ( tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} /> } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index a4bf8e6b4ba..e8907d7abe4 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -15,13 +15,18 @@ import { alertRuleApi } from '../../../api/alertRuleApi'; import { fetchRulerRulesGroup } from '../../../api/ruler'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { useReturnTo } from '../../../hooks/useReturnTo'; -import { RuleFormValues } from '../../../types/rule-form'; +import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; -import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } from '../../../utils/rule-form'; +import { + DEFAULT_GROUP_EVALUATION_INTERVAL, + formValuesToRulerGrafanaRuleDTO, + getDefaultFormValues, + getDefaultQueries, +} from '../../../utils/rule-form'; import { isGrafanaRulerRule } from '../../../utils/rules'; import { FileExportPreview } from '../../export/FileExportPreview'; import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; -import { ExportFormats, allGrafanaExportProviders } from '../../export/providers'; +import { ExportFormats, HclExportProvider, allGrafanaExportProviders } from '../../export/providers'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; @@ -30,18 +35,30 @@ import { NotificationsStep } from '../NotificationsStep'; import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep'; interface ModifyExportRuleFormProps { - alertUid: string; + alertUid?: string; ruleForm?: RuleFormValues; } export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFormProps) { + const defaultValuesForNewRule: RuleFormValues = useMemo(() => { + const defaultRuleType = RuleFormType.grafana; + + return { + ...getDefaultFormValues(), + condition: 'C', + queries: getDefaultQueries(false), + type: defaultRuleType, + evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, + }; + }, []); + const formAPI = useForm({ mode: 'onSubmit', - defaultValues: ruleForm, + defaultValues: ruleForm ?? defaultValuesForNewRule, shouldFocusError: true, }); - const existing = Boolean(ruleForm); // always should be true + const existing = Boolean(ruleForm); const notifyApp = useAppNotification(); const { returnTo } = useReturnTo('/alerting/list'); @@ -129,21 +146,21 @@ interface GrafanaRuleDesignExportPreviewProps { exportFormat: ExportFormats; onClose: () => void; exportValues: RuleFormValues; - uid: string; + uid?: string; } export const getPayloadToExport = ( - uid: string, formValues: RuleFormValues, - existingGroup: RulerRuleGroupDTO | null | undefined + existingGroup: RulerRuleGroupDTO | null | undefined, + ruleUid?: string ): PostableRulerRuleGroupDTO => { const grafanaRuleDto = formValuesToRulerGrafanaRuleDTO(formValues); - const updatedRule = { ...grafanaRuleDto, grafana_alert: { ...grafanaRuleDto.grafana_alert, uid: uid } }; + const updatedRule = { ...grafanaRuleDto, grafana_alert: { ...grafanaRuleDto.grafana_alert, uid: ruleUid } }; if (existingGroup?.rules) { // we have to update the rule in the group in the same position if it exists, otherwise we have to add it at the end let alreadyExistsInGroup = false; const updatedRules = existingGroup.rules.map((rule: RulerRuleDTO) => { - if (isGrafanaRulerRule(rule) && rule.grafana_alert.uid === uid) { + if (isGrafanaRulerRule(rule) && rule.grafana_alert.uid === ruleUid) { alreadyExistsInGroup = true; return updatedRule; } else { @@ -167,11 +184,11 @@ export const getPayloadToExport = ( } }; -const useGetPayloadToExport = (values: RuleFormValues, uid: string) => { +const useGetPayloadToExport = (values: RuleFormValues, ruleUid?: string) => { const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group); const payload: PostableRulerRuleGroupDTO = useMemo(() => { - return getPayloadToExport(uid, values, rulerGroupDto?.value); - }, [uid, rulerGroupDto, values]); + return getPayloadToExport(values, rulerGroupDto?.value, ruleUid); + }, [ruleUid, rulerGroupDto, values]); return { payload, loadingGroup: rulerGroupDto.loading }; }; @@ -187,7 +204,7 @@ const GrafanaRuleDesignExportPreview = ({ const nameSpaceUID = exportValues.folder?.uid ?? ''; useEffect(() => { - !loadingGroup && getExport({ payload, format: exportFormat, nameSpaceUID }); + !loadingGroup && payload.name && getExport({ payload, format: exportFormat, nameSpaceUID }); }, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]); if (exportData.isLoading) { @@ -209,11 +226,14 @@ const GrafanaRuleDesignExportPreview = ({ interface GrafanaRuleDesignExporterProps { onClose: () => void; exportValues: RuleFormValues; - uid: string; + uid?: string; } export const GrafanaRuleDesignExporter = memo(({ onClose, exportValues, uid }: GrafanaRuleDesignExporterProps) => { - const [activeTab, setActiveTab] = useState('yaml'); + const exportingNewRule = !uid; + const initialTab = exportingNewRule ? 'hcl' : 'yaml'; + const [activeTab, setActiveTab] = useState(initialTab); + const formatProviders = exportingNewRule ? [HclExportProvider] : Object.values(allGrafanaExportProviders); return ( { it('should return a ModifyExportPayload with the updated rule added to a group with this rule belongs, in the same position', () => { // for alerting rule - const resultForAlerting = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto); + const resultForAlerting = getPayloadToExport(formValuesForRule2Updated, groupDto, 'uid-rule-2'); expect(resultForAlerting).toEqual({ name: 'Test Group', rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3, rule4], }); // for recording rule const resultForRecording = getPayloadToExport( - 'uid-rule-4', { ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording }, - groupDto + groupDto, + 'uid-rule-4' ); expect(resultForRecording).toEqual({ name: 'Test Group', @@ -180,16 +180,16 @@ describe('getPayloadFromDto', () => { }); it('should return a ModifyExportPayload with the updated rule added to a non empty rule where this rule does not belong, in the last position', () => { // for alerting rule - const result = getPayloadToExport('uid-rule-5', formValuesForRule2Updated, groupDto); + const result = getPayloadToExport(formValuesForRule2Updated, groupDto, 'uid-rule-5'); expect(result).toEqual({ name: 'Test Group', rules: [rule1, rule2, rule3, rule4, expectedModifiedRule2('uid-rule-5')], }); // for recording rule const resultForRecording = getPayloadToExport( - 'uid-rule-5', { ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording }, - groupDto + groupDto, + 'uid-rule-5' ); expect(resultForRecording).toEqual({ name: 'Test Group', @@ -202,7 +202,7 @@ describe('getPayloadFromDto', () => { name: 'Empty Group', rules: [], }; - const result = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, emptyGroupDto); + const result = getPayloadToExport(formValuesForRule2Updated, emptyGroupDto, 'uid-rule-2'); expect(result).toEqual({ name: 'Empty Group', rules: [expectedModifiedRule2('uid-rule-2')], diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx index fbd357bb354..f09745074e4 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx @@ -27,6 +27,7 @@ import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector' import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions'; import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { createRelativeUrl } from '../utils/url'; const VIEWS = { groups: RuleListGroupView, @@ -119,7 +120,17 @@ const RuleListV1 = () => { return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - }> + + + + ) + } + > @@ -169,3 +180,21 @@ export function CreateAlertButton() { } return null; } + +function ExportNewRuleButton() { + const returnTo = location.pathname + location.search; + const url = createRelativeUrl(`/alerting/export-new-rule`, { + returnTo, + }); + return ( + logInfo(LogMessages.exportNewGrafanaRule)} + > + Export new alert rule + + ); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 79baefabb0f..6509714b50e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -280,6 +280,12 @@ "contactPointFilter": { "label": "Contact point" }, + "export": { + "subtitle": { + "formats": "Select the format and download the file or copy the contents to clipboard", + "one-format": "Download the file or copy the contents to clipboard" + } + }, "folderAndGroup": { "evaluation": { "modal": { @@ -301,6 +307,7 @@ "title": "Data source-managed" }, "grafanaManaged": { + "export-new-rule": "Export new alert rule", "export-rules": "Export rules", "loading": "Loading...", "new-recording-rule": "New recording rule", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 2cd1c0a7625..855128db0c9 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -280,6 +280,12 @@ "contactPointFilter": { "label": "Cőʼnŧäčŧ pőįʼnŧ" }, + "export": { + "subtitle": { + "formats": "Ŝęľęčŧ ŧĥę ƒőřmäŧ äʼnđ đőŵʼnľőäđ ŧĥę ƒįľę őř čőpy ŧĥę čőʼnŧęʼnŧş ŧő čľįpþőäřđ", + "one-format": "Đőŵʼnľőäđ ŧĥę ƒįľę őř čőpy ŧĥę čőʼnŧęʼnŧş ŧő čľįpþőäřđ" + } + }, "folderAndGroup": { "evaluation": { "modal": { @@ -301,6 +307,7 @@ "title": "Đäŧä şőūřčę-mäʼnäģęđ" }, "grafanaManaged": { + "export-new-rule": "Ēχpőřŧ ʼnęŵ äľęřŧ řūľę", "export-rules": "Ēχpőřŧ řūľęş", "loading": "Ŀőäđįʼnģ...", "new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę",