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 <konradlalik@gmail.com>

* Update public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>

* address review comments

* update translations

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Sonia Aguilar 2024-11-25 11:52:28 +01:00 committed by GitHub
parent 2736fe0568
commit 624f44fdb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 141 additions and 28 deletions

View File

@ -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', path: '/alerting/:sourceName/:id/view',
pageClass: 'page-alerting', pageClass: 'page-alerting',

View File

@ -27,6 +27,7 @@ export const LogMessages = {
unknownMessageFromError: 'unknown messageFromError', unknownMessageFromError: 'unknown messageFromError',
grafanaRecording: 'creating Grafana recording rule from scratch', grafanaRecording: 'creating Grafana recording rule from scratch',
loadedCentralAlertStateHistory: 'loaded central alert state history', loadedCentralAlertStateHistory: 'loaded central alert state history',
exportNewGrafanaRule: 'exporting new Grafana rule',
}; };
const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger('features.alerting', { const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger('features.alerting', {

View File

@ -120,7 +120,7 @@ const ui = {
rulesFilterInput: byTestId('search-query-input'), rulesFilterInput: byTestId('search-query-input'),
moreErrorsButton: byRole('button', { name: /more errors/ }), moreErrorsButton: byRole('button', { name: /more errors/ }),
editCloudGroupIcon: byTestId('edit-group'), editCloudGroupIcon: byTestId('edit-group'),
newRuleButton: byText(/new alert rule/i), newRuleButton: byRole('link', { name: 'New alert rule' }),
exportButton: byText(/export rules/i), exportButton: byText(/export rules/i),
editGroupModal: { editGroupModal: {
dialog: byRole('dialog'), dialog: byRole('dialog'),

View File

@ -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 (
<ExportNewGrafanaRuleWrapper>
<ModifyExportRuleForm />
</ExportNewGrafanaRuleWrapper>
);
}
interface ExportNewGrafanaRuleWrapperProps {
children: React.ReactNode;
}
function ExportNewGrafanaRuleWrapper({ children }: ExportNewGrafanaRuleWrapperProps) {
return (
<AlertingPageWrapper
navId="alert-list"
pageNav={{
text: 'Export new Grafana rule',
subTitle: 'Export a new rule definition in Terraform(HCL) format. Any changes you make will not be saved.',
}}
>
{children}
</AlertingPageWrapper>
);
}

View File

@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Drawer } from '@grafana/ui'; import { Drawer } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { RuleInspectorTabs } from '../rule-editor/RuleInspector'; import { RuleInspectorTabs } from '../rule-editor/RuleInspector';
@ -27,10 +28,17 @@ export function GrafanaExportDrawer({
label: provider.name, label: provider.name,
value: provider.exportFormat, 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 ( return (
<Drawer <Drawer
title={title} title={title}
subtitle="Select the format and download the file or copy the contents to clipboard" subtitle={subtitle}
tabs={ tabs={
<RuleInspectorTabs<ExportFormats> tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} /> <RuleInspectorTabs<ExportFormats> tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} />
} }

View File

@ -15,13 +15,18 @@ import { alertRuleApi } from '../../../api/alertRuleApi';
import { fetchRulerRulesGroup } from '../../../api/ruler'; import { fetchRulerRulesGroup } from '../../../api/ruler';
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
import { useReturnTo } from '../../../hooks/useReturnTo'; 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 { 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 { isGrafanaRulerRule } from '../../../utils/rules';
import { FileExportPreview } from '../../export/FileExportPreview'; import { FileExportPreview } from '../../export/FileExportPreview';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
import { ExportFormats, allGrafanaExportProviders } from '../../export/providers'; import { ExportFormats, HclExportProvider, allGrafanaExportProviders } from '../../export/providers';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep'; import AnnotationsStep from '../AnnotationsStep';
import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior';
@ -30,18 +35,30 @@ import { NotificationsStep } from '../NotificationsStep';
import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep'; import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep';
interface ModifyExportRuleFormProps { interface ModifyExportRuleFormProps {
alertUid: string; alertUid?: string;
ruleForm?: RuleFormValues; ruleForm?: RuleFormValues;
} }
export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFormProps) { 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<RuleFormValues>({ const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit', mode: 'onSubmit',
defaultValues: ruleForm, defaultValues: ruleForm ?? defaultValuesForNewRule,
shouldFocusError: true, shouldFocusError: true,
}); });
const existing = Boolean(ruleForm); // always should be true const existing = Boolean(ruleForm);
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const { returnTo } = useReturnTo('/alerting/list'); const { returnTo } = useReturnTo('/alerting/list');
@ -129,21 +146,21 @@ interface GrafanaRuleDesignExportPreviewProps {
exportFormat: ExportFormats; exportFormat: ExportFormats;
onClose: () => void; onClose: () => void;
exportValues: RuleFormValues; exportValues: RuleFormValues;
uid: string; uid?: string;
} }
export const getPayloadToExport = ( export const getPayloadToExport = (
uid: string,
formValues: RuleFormValues, formValues: RuleFormValues,
existingGroup: RulerRuleGroupDTO<RulerRuleDTO> | null | undefined existingGroup: RulerRuleGroupDTO<RulerRuleDTO> | null | undefined,
ruleUid?: string
): PostableRulerRuleGroupDTO => { ): PostableRulerRuleGroupDTO => {
const grafanaRuleDto = formValuesToRulerGrafanaRuleDTO(formValues); 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) { 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 // 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; let alreadyExistsInGroup = false;
const updatedRules = existingGroup.rules.map((rule: RulerRuleDTO) => { 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; alreadyExistsInGroup = true;
return updatedRule; return updatedRule;
} else { } 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 rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group);
const payload: PostableRulerRuleGroupDTO = useMemo(() => { const payload: PostableRulerRuleGroupDTO = useMemo(() => {
return getPayloadToExport(uid, values, rulerGroupDto?.value); return getPayloadToExport(values, rulerGroupDto?.value, ruleUid);
}, [uid, rulerGroupDto, values]); }, [ruleUid, rulerGroupDto, values]);
return { payload, loadingGroup: rulerGroupDto.loading }; return { payload, loadingGroup: rulerGroupDto.loading };
}; };
@ -187,7 +204,7 @@ const GrafanaRuleDesignExportPreview = ({
const nameSpaceUID = exportValues.folder?.uid ?? ''; const nameSpaceUID = exportValues.folder?.uid ?? '';
useEffect(() => { useEffect(() => {
!loadingGroup && getExport({ payload, format: exportFormat, nameSpaceUID }); !loadingGroup && payload.name && getExport({ payload, format: exportFormat, nameSpaceUID });
}, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]); }, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]);
if (exportData.isLoading) { if (exportData.isLoading) {
@ -209,11 +226,14 @@ const GrafanaRuleDesignExportPreview = ({
interface GrafanaRuleDesignExporterProps { interface GrafanaRuleDesignExporterProps {
onClose: () => void; onClose: () => void;
exportValues: RuleFormValues; exportValues: RuleFormValues;
uid: string; uid?: string;
} }
export const GrafanaRuleDesignExporter = memo(({ onClose, exportValues, uid }: GrafanaRuleDesignExporterProps) => { export const GrafanaRuleDesignExporter = memo(({ onClose, exportValues, uid }: GrafanaRuleDesignExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml'); const exportingNewRule = !uid;
const initialTab = exportingNewRule ? 'hcl' : 'yaml';
const [activeTab, setActiveTab] = useState<ExportFormats>(initialTab);
const formatProviders = exportingNewRule ? [HclExportProvider] : Object.values(allGrafanaExportProviders);
return ( return (
<GrafanaExportDrawer <GrafanaExportDrawer
@ -221,7 +241,7 @@ export const GrafanaRuleDesignExporter = memo(({ onClose, exportValues, uid }: G
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
onClose={onClose} onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)} formatProviders={formatProviders}
> >
<GrafanaRuleDesignExportPreview <GrafanaRuleDesignExportPreview
exportFormat={activeTab} exportFormat={activeTab}

View File

@ -162,16 +162,16 @@ describe('getPayloadFromDto', () => {
it('should return a ModifyExportPayload with the updated rule added to a group with this rule belongs, in the same position', () => { it('should return a ModifyExportPayload with the updated rule added to a group with this rule belongs, in the same position', () => {
// for alerting rule // for alerting rule
const resultForAlerting = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto); const resultForAlerting = getPayloadToExport(formValuesForRule2Updated, groupDto, 'uid-rule-2');
expect(resultForAlerting).toEqual({ expect(resultForAlerting).toEqual({
name: 'Test Group', name: 'Test Group',
rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3, rule4], rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3, rule4],
}); });
// for recording rule // for recording rule
const resultForRecording = getPayloadToExport( const resultForRecording = getPayloadToExport(
'uid-rule-4',
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording }, { ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
groupDto groupDto,
'uid-rule-4'
); );
expect(resultForRecording).toEqual({ expect(resultForRecording).toEqual({
name: 'Test Group', 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', () => { 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 // for alerting rule
const result = getPayloadToExport('uid-rule-5', formValuesForRule2Updated, groupDto); const result = getPayloadToExport(formValuesForRule2Updated, groupDto, 'uid-rule-5');
expect(result).toEqual({ expect(result).toEqual({
name: 'Test Group', name: 'Test Group',
rules: [rule1, rule2, rule3, rule4, expectedModifiedRule2('uid-rule-5')], rules: [rule1, rule2, rule3, rule4, expectedModifiedRule2('uid-rule-5')],
}); });
// for recording rule // for recording rule
const resultForRecording = getPayloadToExport( const resultForRecording = getPayloadToExport(
'uid-rule-5',
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording }, { ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
groupDto groupDto,
'uid-rule-5'
); );
expect(resultForRecording).toEqual({ expect(resultForRecording).toEqual({
name: 'Test Group', name: 'Test Group',
@ -202,7 +202,7 @@ describe('getPayloadFromDto', () => {
name: 'Empty Group', name: 'Empty Group',
rules: [], rules: [],
}; };
const result = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, emptyGroupDto); const result = getPayloadToExport(formValuesForRule2Updated, emptyGroupDto, 'uid-rule-2');
expect(result).toEqual({ expect(result).toEqual({
name: 'Empty Group', name: 'Empty Group',
rules: [expectedModifiedRule2('uid-rule-2')], rules: [expectedModifiedRule2('uid-rule-2')],

View File

@ -27,6 +27,7 @@ import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector'
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions'; import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { createRelativeUrl } from '../utils/url';
const VIEWS = { const VIEWS = {
groups: RuleListGroupView, groups: RuleListGroupView,
@ -119,7 +120,17 @@ const RuleListV1 = () => {
return ( return (
// We don't want to show the Loading... indicator for the whole page. // We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules // We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}> <AlertingPageWrapper
navId="alert-list"
isLoading={false}
actions={
hasAlertRulesCreated && (
<Stack gap={1}>
<CreateAlertButton /> <ExportNewRuleButton />
</Stack>
)
}
>
<Stack direction="column"> <Stack direction="column">
<RuleListErrors /> <RuleListErrors />
<RulesFilter onClear={onFilterCleared} /> <RulesFilter onClear={onFilterCleared} />
@ -169,3 +180,21 @@ export function CreateAlertButton() {
} }
return null; return null;
} }
function ExportNewRuleButton() {
const returnTo = location.pathname + location.search;
const url = createRelativeUrl(`/alerting/export-new-rule`, {
returnTo,
});
return (
<LinkButton
href={url}
icon="download-alt"
variant="secondary"
tooltip="Export new grafana rule"
onClick={() => logInfo(LogMessages.exportNewGrafanaRule)}
>
<Trans i18nKey="alerting.list-view.section.grafanaManaged.export-new-rule">Export new alert rule</Trans>
</LinkButton>
);
}

View File

@ -280,6 +280,12 @@
"contactPointFilter": { "contactPointFilter": {
"label": "Contact point" "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": { "folderAndGroup": {
"evaluation": { "evaluation": {
"modal": { "modal": {
@ -301,6 +307,7 @@
"title": "Data source-managed" "title": "Data source-managed"
}, },
"grafanaManaged": { "grafanaManaged": {
"export-new-rule": "Export new alert rule",
"export-rules": "Export rules", "export-rules": "Export rules",
"loading": "Loading...", "loading": "Loading...",
"new-recording-rule": "New recording rule", "new-recording-rule": "New recording rule",

View File

@ -280,6 +280,12 @@
"contactPointFilter": { "contactPointFilter": {
"label": "Cőʼnŧäčŧ pőįʼnŧ" "label": "Cőʼnŧäčŧ pőįʼnŧ"
}, },
"export": {
"subtitle": {
"formats": "Ŝęľęčŧ ŧĥę ƒőřmäŧ äʼnđ đőŵʼnľőäđ ŧĥę ƒįľę őř čőpy ŧĥę čőʼnŧęʼnŧş ŧő čľįpþőäřđ",
"one-format": "Đőŵʼnľőäđ ŧĥę ƒįľę őř čőpy ŧĥę čőʼnŧęʼnŧş ŧő čľįpþőäřđ"
}
},
"folderAndGroup": { "folderAndGroup": {
"evaluation": { "evaluation": {
"modal": { "modal": {
@ -301,6 +307,7 @@
"title": "Đäŧä şőūřčę-mäʼnäģęđ" "title": "Đäŧä şőūřčę-mäʼnäģęđ"
}, },
"grafanaManaged": { "grafanaManaged": {
"export-new-rule": "Ēχpőřŧ ʼnęŵ äľęřŧ řūľę",
"export-rules": "Ēχpőřŧ řūľęş", "export-rules": "Ēχpőřŧ řūľęş",
"loading": "Ŀőäđįʼnģ...", "loading": "Ŀőäđįʼnģ...",
"new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę", "new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę",