Alerting: Add Grafana-managed groups and rules export (#74522)

This commit is contained in:
Konrad Lalik 2023-09-08 16:26:54 +02:00 committed by GitHub
parent 699c5c1e2e
commit e7a2c95586
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 601 additions and 201 deletions

View File

@ -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 ? (
<HorizontalGroup height="auto" justify="flex-end">
<Button variant="secondary" type="button" onClick={() => setShowYaml(true)} size="sm">
View YAML
Export
</Button>
</HorizontalGroup>
) : null;
@ -39,7 +39,7 @@ const RuleViewer = (props: RuleViewerProps): JSX.Element => {
return (
<AlertingPageWrapper>
<AppChromeUpdate actions={actionButtons} />
{showYaml && <GrafanaRuleInspector alertUid={uidFromParams} onClose={() => setShowYaml(false)} />}
{showYaml && <GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowYaml(false)} />}
<Enable feature={AlertingFeature.DetailsViewV2}>
<DetailViewV2 {...props} />
</Enable>

View File

@ -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<string, string>;
export const alertRuleApi = alertingApi.injectEndpoints({
@ -178,8 +182,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
}),
exportRule: build.query<string, { uid: string; format: 'yaml' | 'json' }>({
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format) }),
exportRule: build.query<string, { uid: string; format: RuleExportFormats }>({
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format), responseType: 'text' }),
}),
exportRuleGroup: build.query<string, { folderUid: string; groupName: string; format: RuleExportFormats }>({
query: ({ folderUid, groupName, format }) => ({
url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`,
params: { format: format },
responseType: 'text',
}),
}),
}),
});

View File

@ -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
<div className={styles.container}>
<div className={styles.content}>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language={format}
value={formattedTextDefinition}
monacoOptions={{
minimap: {
enabled: false,
},
lineNumbers: 'on',
readOnly: true,
}}
/>
)}
</AutoSizer>
</div>
<div className={styles.actions}>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<ClipboardButton icon="copy" getText={() => textDefinition}>
Copy code
</ClipboardButton>
<Button icon="download-alt" onClick={onDownload}>
Download
</Button>
</div>
</div>
);
}
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)};
`,
});

View File

@ -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 (
<Drawer
title="Export"
subtitle="Select the format and download the file or copy the contents to clipboard"
tabs={
<RuleInspectorTabs<RuleExportFormats>
tabs={grafanaRulesTabs}
setActiveTab={onTabChange}
activeTab={activeTab}
/>
}
onClose={onClose}
size="md"
>
{children}
</Drawer>
);
}

View File

@ -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<RuleExportFormats>('yaml');
return (
<GrafanaExportDrawer activeTab={activeTab} onTabChange={setActiveTab} onClose={onClose}>
<GrafanaRuleExportPreview alertUid={alertUid} exportFormat={activeTab} onClose={onClose} />
</GrafanaExportDrawer>
);
};
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 <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={ruleTextDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};

View File

@ -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<RuleExportFormats>('yaml');
return (
<GrafanaExportDrawer activeTab={activeTab} onTabChange={setActiveTab} onClose={onClose}>
<GrafanaRuleGroupExportPreview
folderUid={folderUid}
groupName={groupName}
exportFormat={activeTab}
onClose={onClose}
/>
</GrafanaExportDrawer>
);
}
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 <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={ruleGroupTextDefinition}
downloadFileName={groupName}
onClose={onClose}
/>
);
}

View File

@ -0,0 +1,36 @@
interface RuleExportProvider<TFormat> {
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;

View File

@ -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'}
</Button>
) : null}
</HorizontalGroup>
@ -316,7 +316,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
) : null}
{showEditYaml ? (
type === RuleFormType.grafana ? (
<GrafanaRuleInspector alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
) : (
<RuleInspector onClose={() => setShowEditYaml(false)} />
)

View File

@ -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 (
<Drawer
title="Inspect Alert rule"
subtitle={
<div className={styles.subtitle}>
<RuleInspectorSubtitle setActiveTab={setActiveTab} activeTab={activeTab} />
</div>
}
onClose={onClose}
>
{activeTab === 'yaml' && <GrafanaInspectorYamlTab alertUid={alertUid} />}
</Drawer>
);
};
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 <div>Loading...</div>;
}
return (
<>
<div className={styles.content}>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language="yaml"
value={yamlRule || ''}
monacoOptions={{
minimap: {
enabled: false,
},
}}
/>
)}
</AutoSizer>
</div>
</>
);
};

View File

@ -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={
<div className={styles.subtitle}>
<RuleInspectorSubtitle setActiveTab={setActiveTab} activeTab={activeTab} />
<RuleInspectorTabs tabs={cloudRulesTabs} setActiveTab={setActiveTab} activeTab={activeTab} />
</div>
}
onClose={onClose}
@ -52,12 +52,13 @@ export const RuleInspector = ({ onClose }: Props) => {
);
};
interface SubtitleProps {
activeTab: string;
setActiveTab: (tab: string) => void;
interface RuleInspectorTabsProps<T = string> {
tabs: Array<{ label: string; value: T }>;
activeTab: T;
setActiveTab: (tab: T) => void;
}
export const RuleInspectorSubtitle = ({ activeTab, setActiveTab }: SubtitleProps) => {
export function RuleInspectorTabs<T extends string>({ tabs, activeTab, setActiveTab }: RuleInspectorTabsProps<T>) {
return (
<TabsBar>
{tabs.map((tab, index) => {
@ -73,7 +74,7 @@ export const RuleInspectorSubtitle = ({ activeTab, setActiveTab }: SubtitleProps
})}
</TabsBar>
);
};
}
interface YamlTabProps {
onSubmit: (newModel: RuleFormValues) => void;

View File

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

View File

@ -255,7 +255,7 @@ function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
return (
<DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}>
{rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule" />
{rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule UID" />
</DetailsField>
);
}

View File

@ -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 <Redirect to={cloneUrl} push />;
}
return (
<ConfirmModal
isOpen={stage === 'confirm'}
title="Copy provisioned alert rule"
body={
<div>
<p>
The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule.
</p>
<p>
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.
</p>
</div>
}
confirmText="Copy"
onConfirm={() => setStage('redirect')}
onDismiss={onDismiss}
/>
);
}
interface CloneRuleButtonProps {
ruleIdentifier: RuleIdentifier;
isProvisioned: boolean;
text?: string;
className?: string;
}
export const CloneRuleButton = React.forwardRef<HTMLButtonElement, CloneRuleButtonProps>(
({ text, ruleIdentifier, isProvisioned, className }, ref) => {
const [redirectToClone, setRedirectToClone] = useState(false);
return (
<>
<Button
title="Copy"
className={className}
size="sm"
key="clone"
variant="secondary"
icon="copy"
onClick={() => setRedirectToClone(true)}
ref={ref}
>
{text}
</Button>
{redirectToClone && (
<RedirectToCloneRule
identifier={ruleIdentifier}
isProvisioned={isProvisioned}
onDismiss={() => setRedirectToClone(false)}
/>
)}
</>
);
}
);
CloneRuleButton.displayName = 'CloneRuleButton';
const getStyles = (theme: GrafanaTheme2) => ({
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
});

View File

@ -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<HTMLAnchorElement, CloneRuleButtonProps>(
({ 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 (
<>
<LinkButton
title="Copy"
className={className}
size="sm"
key="clone"
variant="secondary"
icon="copy"
href={isProvisioned ? undefined : cloneUrl}
onClick={isProvisioned ? () => setShowModal(true) : undefined}
ref={ref}
>
{text}
</LinkButton>
<ConfirmModal
isOpen={showModal}
title="Copy provisioned alert rule"
body={
<div>
<p>
The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule.
</p>
<p>
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.
</p>
</div>
}
confirmText="Copy"
onConfirm={() => {
locationService.push(cloneUrl);
}}
onDismiss={() => setShowModal(false)}
/>
</>
);
}
);
CloneRuleButton.displayName = 'CloneRuleButton';
const getStyles = (theme: GrafanaTheme2) => ({
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
});

View File

@ -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<CombinedRule>();
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(
<Tooltip placement="top" content="Copy">
<CloneRuleButton ruleIdentifier={identifier} isProvisioned={isProvisioned} className={style.button} />
</Tooltip>
if (isGrafanaRulerRule(rulerRule) && canReadProvisioning) {
moreActions.push(<Menu.Item label="Export" icon="download-alt" onClick={toggleShowExportDrawer} />);
}
moreActions.push(
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
);
}
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
buttons.push(
<Tooltip placement="top" content={'Delete'}>
<Button
title="Delete"
className={style.button}
size="sm"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
/>
</Tooltip>
);
moreActions.push(<Menu.Item label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />);
}
if (buttons.length) {
@ -149,6 +162,20 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
{buttons.map((button, index) => (
<React.Fragment key={index}>{button}</React.Fragment>
))}
<Dropdown
overlay={
<Menu>
{moreActions.map((action) => (
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
))}
</Menu>
}
>
<Button variant="secondary" size="sm">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
</Stack>
{!!ruleToDelete && (
<ConfirmModal
@ -169,6 +196,16 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
onDismiss={() => setRuleToDelete(undefined)}
/>
)}
{showExportDrawer && isGrafanaRulerRule(rule.rulerRule) && (
<GrafanaRuleExporter alertUid={rule.rulerRule.grafana_alert.uid} onClose={toggleShowExportDrawer} />
)}
{redirectToClone && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}
isProvisioned={redirectToClone.isProvisioned}
onDismiss={() => setRedirectToClone(undefined)}
/>
)}
</>
);
}

View File

@ -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;

View File

@ -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 }) => <textarea data-testid="code-editor" value={value} readOnly />,
}));
const mocks = {
useHasRuler: jest.mocked(useHasRuler),
};
@ -41,12 +51,29 @@ beforeEach(() => {
const ui = {
editGroupButton: byTestId('edit-group'),
deleteGroupButton: byTestId('delete-group'),
exportGroupButton: byRole('button', { name: 'Export rule group' }),
confirmDeleteModal: {
header: byText('Delete group'),
confirmButton: byText('Delete'),
},
moreActionsButton: byRole('button', { name: 'More' }),
export: {
dialog: byRole('dialog', { name: 'Drawer title Export' }),
jsonTab: byRole('tab', { name: /JSON/ }),
yamlTab: byRole('tab', { name: /YAML/ }),
editor: byTestId('code-editor'),
copyCodeButton: byRole('button', { name: 'Copy code' }),
downloadButton: byRole('button', { name: 'Download' }),
},
loadingSpinner: byTestId('spinner'),
};
const server = setupMswServer();
afterEach(() => {
server.resetHandlers();
});
describe('Rules group tests', () => {
const store = configureStore();
@ -58,10 +85,16 @@ describe('Rules group tests', () => {
);
}
describe('When the datasource is grafana', () => {
describe('Grafana rules', () => {
const group: CombinedRuleGroup = {
name: 'TestGroup',
rules: [mockCombinedRule()],
rules: [
mockCombinedRule({
rulerRule: mockGrafanaRulerRule({
namespace_uid: 'cpu-usage',
}),
}),
],
totals: {},
};
@ -80,9 +113,37 @@ describe('Rules group tests', () => {
expect(ui.deleteGroupButton.query()).not.toBeInTheDocument();
expect(ui.editGroupButton.query()).not.toBeInTheDocument();
});
it('Should allow exporting rules group', async () => {
// Arrange
mockUseHasRuler(true, true);
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage' }));
mockProvisioningApi(server).exportRuleGroup('cpu-usage', 'TestGroup', {
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
const user = userEvent.setup();
// Act
renderRulesGroup(namespace, group);
await user.click(await ui.exportGroupButton.find());
// Assert
const drawer = await ui.export.dialog.find();
expect(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
await user.click(ui.export.jsonTab.get(drawer));
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json Export Content');
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument();
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument();
});
});
describe('When the datasource is not grafana', () => {
describe('Cloud rules', () => {
beforeEach(() => {
contextSrv.isEditor = true;
});

View File

@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import pluralize from 'pluralize';
import React, { useEffect, useState } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
@ -9,16 +10,19 @@ import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles
import { useDispatch } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { contextSrv } from '../../../../../core/services/context_srv';
import { LogMessages } from '../../Analytics';
import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
import { provisioningPermissions } from '../../utils/access-control';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter';
import { ActionIcon } from './ActionIcon';
import { EditCloudGroupModal } from './EditRuleGroupModal';
@ -43,6 +47,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const [isReorderingGroup, setIsReorderingGroup] = useState(false);
const [isExporting, toggleIsExporting] = useToggle(false);
const [isCollapsed, setIsCollapsed] = useState(!expandAll);
const { canEditRules } = useRulesAccess();
@ -61,6 +66,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule);
const isFederated = isFederatedRuleGroup(group);
const canReadProvisioning = contextSrv.hasPermission(provisioningPermissions.read);
// check if group has provisioned items
const isProvisioned = group.rules.some((rule) => {
return isGrafanaRulerRule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance;
@ -112,6 +118,18 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
/>
);
}
if (isGroupView && canReadProvisioning) {
actionIcons.push(
<ActionIcon
aria-label="xport rule group"
data-testid="export-group"
key="export"
icon="download-alt"
tooltip="Export rule group"
onClick={() => toggleIsExporting(true)}
/>
);
}
if (isListView) {
actionIcons.push(
<ActionIcon
@ -272,6 +290,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
onDismiss={() => setIsDeletingGroup(false)}
confirmText="Delete"
/>
{isExporting && folder?.uid && (
<GrafanaRuleGroupExporter folderUid={folder?.uid} groupName={group.name} onClose={toggleIsExporting} />
)}
</div>
);
});
@ -288,6 +309,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0;
flex-wrap: nowrap;
border-bottom: 1px solid ${theme.colors.border.weak};
&:hover {
background-color: ${theme.components.table.rowHoverBackground};
}

View File

@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
@ -23,7 +24,10 @@ const ui = {
actionButtons: {
edit: byRole('link', { name: 'Edit' }),
view: byRole('link', { name: 'View' }),
delete: byRole('button', { name: 'Delete' }),
more: byRole('button', { name: 'More' }),
},
moreActionItems: {
delete: byRole('menuitem', { name: 'Delete' }),
},
};
@ -50,10 +54,13 @@ describe('RulesTable RBAC', () => {
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
});
it('Should not render Delete button for users without the delete permission', () => {
it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
const user = userEvent.setup();
renderRulesTable(grafanaRule);
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
});
it('Should render Edit button for users with the update permission', () => {
@ -62,10 +69,15 @@ describe('RulesTable RBAC', () => {
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
});
it('Should render Delete button for users with the delete permission', () => {
it('Should render Delete button for users with the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
const user = userEvent.setup();
renderRulesTable(grafanaRule);
expect(ui.actionButtons.delete.get()).toBeInTheDocument();
expect(ui.actionButtons.more.get()).toBeInTheDocument();
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.get()).toBeInTheDocument();
});
});
@ -77,10 +89,13 @@ describe('RulesTable RBAC', () => {
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
});
it('Should not render Delete button for users without the delete permission', () => {
it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
const user = userEvent.setup();
renderRulesTable(cloudRule);
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
});
it('Should render Edit button for users with the update permission', () => {
@ -89,10 +104,13 @@ describe('RulesTable RBAC', () => {
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
});
it('Should render Delete button for users with the delete permission', () => {
it('Should render Delete button for users with the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
const user = userEvent.setup();
renderRulesTable(cloudRule);
expect(ui.actionButtons.delete.get()).toBeInTheDocument();
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.get()).toBeInTheDocument();
});
});
});

View File

@ -22,7 +22,7 @@ import {
MatcherOperator,
Route,
} from '../../../plugins/datasource/alertmanager/types';
import { NotifierDTO } from '../../../types';
import { FolderDTO, NotifierDTO } from '../../../types';
import { CreateIntegrationDTO, NewOnCallIntegrationDTO, OnCallIntegrationDTO } from './api/onCallApi';
import { AlertingQueryResponse } from './state/AlertingQueryRunner';
@ -60,9 +60,11 @@ class AlertmanagerRouteBuilder {
this.route.receiver = receiver;
return this;
}
withoutReceiver(): AlertmanagerRouteBuilder {
return this;
}
withEmptyReceiver(): AlertmanagerRouteBuilder {
this.route.receiver = '';
return this;
@ -316,6 +318,26 @@ export function mockFeatureDiscoveryApi(server: SetupServer) {
};
}
export function mockProvisioningApi(server: SetupServer) {
return {
exportRuleGroup: (folderUid: string, groupName: string, response: Record<string, string>) => {
server.use(
rest.get(`/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`, (req, res, ctx) =>
res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']))
)
);
},
};
}
export function mockFolderApi(server: SetupServer) {
return {
folder: (folderUid: string, response: FolderDTO) => {
server.use(rest.get(`/api/folders/${folderUid}`, (_, res, ctx) => res(ctx.status(200), ctx.json(response))));
},
};
}
// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it
export function setupMswServer() {
const server = setupServer();