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',
pageClass: 'page-alerting',

View File

@ -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', {

View File

@ -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'),

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 { 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 (
<Drawer
title={title}
subtitle="Select the format and download the file or copy the contents to clipboard"
subtitle={subtitle}
tabs={
<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 { 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<RuleFormValues>({
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<RulerRuleDTO> | null | undefined
existingGroup: RulerRuleGroupDTO<RulerRuleDTO> | 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<ExportFormats>('yaml');
const exportingNewRule = !uid;
const initialTab = exportingNewRule ? 'hcl' : 'yaml';
const [activeTab, setActiveTab] = useState<ExportFormats>(initialTab);
const formatProviders = exportingNewRule ? [HclExportProvider] : Object.values(allGrafanaExportProviders);
return (
<GrafanaExportDrawer
@ -221,7 +241,7 @@ export const GrafanaRuleDesignExporter = memo(({ onClose, exportValues, uid }: G
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)}
formatProviders={formatProviders}
>
<GrafanaRuleDesignExportPreview
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', () => {
// 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')],

View File

@ -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
<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">
<RuleListErrors />
<RulesFilter onClear={onFilterCleared} />
@ -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 (
<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": {
"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",

View File

@ -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ģ řūľę",