mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add Grafana-managed groups and rules export (#74522)
This commit is contained in:
parent
699c5c1e2e
commit
e7a2c95586
@ -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>
|
||||
|
@ -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',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -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)};
|
||||
`,
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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)} />
|
||||
)
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
||||
|
@ -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' }),
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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};
|
||||
`,
|
||||
});
|
@ -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};
|
||||
`,
|
||||
});
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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};
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user