Alerting: Add export drawer with yaml and json formats, in policies and contact points view. (#74613)

* Add export formats drawer when exporting contact points

* Add 'export by format' drawer in policies (root policy)

* Add test for showing export policies button

* Add tests for Policy.tsx

* Add tests for export functionality in receivers

* Add exporter drawer for receivers

* Fix prettier warnings

* Allow HCL only for alert rules exports

* Add tests for Policies

* Fix tests

* Refactor: Update ExportProviders types for limiting the avaliable export formats when using GrafanaExportDrawer

* Delete unused shouldShowExportOption method and tests

* Use useAlertmanagerAbility hook to check if canReadSecrets

* Update snapshot for useAbilities test

* Fix prettier

* Convert decrypt to boolean

* Fix prettier

* Rename CanReadSecrets action to DecryptSecrets

* Update the string value for DecryptSecrets

* Fix snapshor for useAbilities after renaming the can-read-secrets
This commit is contained in:
Sonia Aguilar 2023-09-18 08:36:50 +02:00 committed by GitHub
parent 40c12c17bf
commit 5484e0a2d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 538 additions and 152 deletions

View File

@ -11,7 +11,7 @@ import {
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { RuleExportFormats } from '../components/export/providers';
import { ExportFormats } 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';
@ -40,7 +40,7 @@ export interface Datasource {
export const PREVIEW_URL = '/api/v1/rule/test/grafana';
export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules';
function getProvisioningUrl(ruleUid: string, format: RuleExportFormats = 'yaml') {
function getProvisioningExportUrl(ruleUid: string, format: 'yaml' | 'json' | 'hcl' = 'yaml') {
return `/api/v1/provisioning/alert-rules/${ruleUid}/export?format=${format}`;
}
@ -182,15 +182,36 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
}),
exportRule: build.query<string, { uid: string; format: RuleExportFormats }>({
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format), responseType: 'text' }),
exportRule: build.query<string, { uid: string; format: ExportFormats }>({
query: ({ uid, format }) => ({ url: getProvisioningExportUrl(uid, format), responseType: 'text' }),
}),
exportRuleGroup: build.query<string, { folderUid: string; groupName: string; format: RuleExportFormats }>({
exportRuleGroup: build.query<string, { folderUid: string; groupName: string; format: ExportFormats }>({
query: ({ folderUid, groupName, format }) => ({
url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`,
params: { format: format },
responseType: 'text',
}),
}),
exportReceiver: build.query<string, { receiverName: string; decrypt: boolean; format: ExportFormats }>({
query: ({ receiverName, decrypt, format }) => ({
url: `/api/v1/provisioning/contact-points/export/`,
params: { format: format, decrypt: decrypt, name: receiverName },
responseType: 'text',
}),
}),
exportReceivers: build.query<string, { decrypt: boolean; format: ExportFormats }>({
query: ({ decrypt, format }) => ({
url: `/api/v1/provisioning/contact-points/export/`,
params: { format: format, decrypt: decrypt },
responseType: 'text',
}),
}),
exportPolicies: build.query<string, { format: ExportFormats }>({
query: ({ format }) => ({
url: `/api/v1/provisioning/policies/export/`,
params: { format: format },
responseType: 'text',
}),
}),
}),
});

View File

@ -6,10 +6,10 @@ 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';
import { allGrafanaExportProviders, ExportFormats } from './providers';
interface FileExportPreviewProps {
format: RuleExportFormats;
format: ExportFormats;
textDefinition: string;
/*** Filename without extension ***/
@ -30,7 +30,7 @@ export function FileExportPreview({ format, textDefinition, downloadFileName, on
}, [textDefinition, downloadFileName, format, onClose]);
const formattedTextDefinition = useMemo(() => {
const provider = grafanaRuleExportProviders[format];
const provider = allGrafanaExportProviders[format];
return provider.formatter ? provider.formatter(textDefinition) : textDefinition;
}, [format, textDefinition]);

View File

@ -4,31 +4,34 @@ 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,
}));
import { ExportFormats, ExportProvider } from './providers';
interface GrafanaExportDrawerProps {
activeTab: RuleExportFormats;
onTabChange: (tab: RuleExportFormats) => void;
activeTab: ExportFormats;
onTabChange: (tab: ExportFormats) => void;
children: React.ReactNode;
onClose: () => void;
formatProviders: Array<ExportProvider<ExportFormats>>;
}
export function GrafanaExportDrawer({ activeTab, onTabChange, children, onClose }: GrafanaExportDrawerProps) {
export function GrafanaExportDrawer({
activeTab,
onTabChange,
children,
onClose,
formatProviders,
}: GrafanaExportDrawerProps) {
const grafanaRulesTabs = Object.values(formatProviders).map((provider) => ({
label: provider.name,
value: provider.exportFormat,
}));
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}
/>
<RuleInspectorTabs<ExportFormats> tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} />
}
onClose={onClose}
size="md"

View File

@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { alertRuleApi } from '../../api/alertRuleApi';
import { FileExportPreview } from './FileExportPreview';
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers';
interface GrafanaPoliciesPreviewProps {
exportFormat: ExportFormats;
onClose: () => void;
}
const GrafanaPoliciesExporterPreview = ({ exportFormat, onClose }: GrafanaPoliciesPreviewProps) => {
const { currentData: policiesDefinition = '', isFetching } = alertRuleApi.useExportPoliciesQuery({
format: exportFormat,
});
const downloadFileName = `policies-${new Date().getTime()}`;
if (isFetching) {
return <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={policiesDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};
interface GrafanaPoliciesExporterProps {
onClose: () => void;
}
export const GrafanaPoliciesExporter = ({ onClose }: GrafanaPoliciesExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={jsonAndYamlGrafanaExportProviders}
>
<GrafanaPoliciesExporterPreview exportFormat={activeTab} onClose={onClose} />
</GrafanaExportDrawer>
);
};

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { alertRuleApi } from '../../api/alertRuleApi';
import { FileExportPreview } from './FileExportPreview';
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers';
interface GrafanaReceiverExportPreviewProps {
exportFormat: ExportFormats;
onClose: () => void;
receiverName: string;
decrypt: boolean;
}
const GrafanaReceiverExportPreview = ({
receiverName,
decrypt,
exportFormat,
onClose,
}: GrafanaReceiverExportPreviewProps) => {
const { currentData: receiverDefinition = '', isFetching } = alertRuleApi.useExportReceiverQuery({
receiverName: receiverName,
decrypt: decrypt,
format: exportFormat,
});
const downloadFileName = `cp-${receiverName}-${new Date().getTime()}`;
if (isFetching) {
return <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={receiverDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};
interface GrafanaReceiverExporterProps {
onClose: () => void;
receiverName: string;
decrypt: boolean;
}
export const GrafanaReceiverExporter = ({ onClose, receiverName, decrypt }: GrafanaReceiverExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={jsonAndYamlGrafanaExportProviders}
>
<GrafanaReceiverExportPreview
receiverName={receiverName}
decrypt={decrypt}
exportFormat={activeTab}
onClose={onClose}
/>
</GrafanaExportDrawer>
);
};

View File

@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { alertRuleApi } from '../../api/alertRuleApi';
import { FileExportPreview } from './FileExportPreview';
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
import { ExportFormats, jsonAndYamlGrafanaExportProviders } from './providers';
interface GrafanaReceiversExportPreviewProps {
exportFormat: ExportFormats;
onClose: () => void;
decrypt: boolean;
}
const GrafanaReceiversExportPreview = ({ decrypt, exportFormat, onClose }: GrafanaReceiversExportPreviewProps) => {
const { currentData: receiverDefinition = '', isFetching } = alertRuleApi.useExportReceiversQuery({
decrypt: decrypt,
format: exportFormat,
});
const downloadFileName = `contact-points-${new Date().getTime()}`;
if (isFetching) {
return <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={receiverDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};
interface GrafanaReceiversExporterProps {
onClose: () => void;
decrypt: boolean;
}
export const GrafanaReceiversExporter = ({ onClose, decrypt }: GrafanaReceiversExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={jsonAndYamlGrafanaExportProviders}
>
<GrafanaReceiversExportPreview decrypt={decrypt} exportFormat={activeTab} onClose={onClose} />
</GrafanaExportDrawer>
);
};

View File

@ -6,26 +6,11 @@ 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>
);
};
import { allGrafanaExportProviders, ExportFormats } from './providers';
interface GrafanaRuleExportPreviewProps {
alertUid: string;
exportFormat: RuleExportFormats;
exportFormat: ExportFormats;
onClose: () => void;
}
@ -50,3 +35,23 @@ const GrafanaRuleExportPreview = ({ alertUid, exportFormat, onClose }: GrafanaRu
/>
);
};
interface GrafanaRulerExporterProps {
onClose: () => void;
alertUid: string;
}
export const GrafanaRuleExporter = ({ onClose, alertUid }: GrafanaRulerExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)}
>
<GrafanaRuleExportPreview alertUid={alertUid} exportFormat={activeTab} onClose={onClose} />
</GrafanaExportDrawer>
);
};

View File

@ -6,7 +6,7 @@ import { alertRuleApi } from '../../api/alertRuleApi';
import { FileExportPreview } from './FileExportPreview';
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
import { RuleExportFormats } from './providers';
import { allGrafanaExportProviders, ExportFormats } from './providers';
interface GrafanaRuleGroupExporterProps {
folderUid: string;
@ -15,10 +15,15 @@ interface GrafanaRuleGroupExporterProps {
}
export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: GrafanaRuleGroupExporterProps) {
const [activeTab, setActiveTab] = useState<RuleExportFormats>('yaml');
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer activeTab={activeTab} onTabChange={setActiveTab} onClose={onClose}>
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)}
>
<GrafanaRuleGroupExportPreview
folderUid={folderUid}
groupName={groupName}
@ -32,7 +37,7 @@ export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: Graf
interface GrafanaRuleGroupExportPreviewProps {
folderUid: string;
groupName: string;
exportFormat: RuleExportFormats;
exportFormat: ExportFormats;
onClose: () => void;
}

View File

@ -1,10 +1,10 @@
interface RuleExportProvider<TFormat> {
export interface ExportProvider<TFormat> {
name: string;
exportFormat: TFormat;
formatter?: (raw: string) => string;
}
const JsonRuleExportProvider: RuleExportProvider<'json'> = {
export const JsonExportProvider: ExportProvider<'json'> = {
name: 'JSON',
exportFormat: 'json',
formatter: (raw: string) => {
@ -16,20 +16,22 @@ const JsonRuleExportProvider: RuleExportProvider<'json'> = {
},
};
const YamlRuleExportProvider: RuleExportProvider<'yaml'> = {
export const YamlExportProvider: ExportProvider<'yaml'> = {
name: 'YAML',
exportFormat: 'yaml',
};
const HclRuleExportProvider: RuleExportProvider<'hcl'> = {
export const HclExportProvider: ExportProvider<'hcl'> = {
name: 'Terraform (HCL)',
exportFormat: 'hcl',
};
export const grafanaRuleExportProviders = {
[JsonRuleExportProvider.exportFormat]: JsonRuleExportProvider,
[YamlRuleExportProvider.exportFormat]: YamlRuleExportProvider,
[HclRuleExportProvider.exportFormat]: HclRuleExportProvider,
export const allGrafanaExportProviders = {
[JsonExportProvider.exportFormat]: JsonExportProvider,
[YamlExportProvider.exportFormat]: YamlExportProvider,
[HclExportProvider.exportFormat]: HclExportProvider,
} as const;
export type RuleExportFormats = keyof typeof grafanaRuleExportProviders;
export const jsonAndYamlGrafanaExportProviders = [JsonExportProvider, YamlExportProvider];
export type ExportFormats = keyof typeof allGrafanaExportProviders;

View File

@ -1,4 +1,4 @@
import { screen, render, within } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash';
import React from 'react';
@ -14,19 +14,28 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types/alerting';
import { useAlertmanagerAbilities } from '../../hooks/useAbilities';
import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { Policy } from './Policy';
beforeAll(() => {
userEvent.setup();
});
jest.mock('../../hooks/useAbilities', () => ({
...jest.requireActual('../../hooks/useAbilities'),
useAlertmanagerAbilities: jest.fn(),
}));
const useAlertmanagerAbilitiesMock = jest.mocked(useAlertmanagerAbilities);
describe('Policy', () => {
beforeAll(() => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
useAlertmanagerAbilitiesMock.mockReturnValue([
[true, true],
[true, true],
[true, true],
]);
});
it('should render a policy tree', async () => {
@ -38,6 +47,7 @@ describe('Policy', () => {
);
const routeTree = mockRoutes;
const user = userEvent.setup();
renderPolicy(
<Policy
@ -58,13 +68,13 @@ describe('Policy', () => {
// click "more actions" and check if we can edit and delete
expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument();
await userEvent.click(within(defaultPolicy).getByTestId('more-actions'));
await user.click(within(defaultPolicy).getByTestId('more-actions'));
// should be editable
const editDefaultPolicy = screen.getByRole('menuitem', { name: 'Edit' });
expect(editDefaultPolicy).toBeInTheDocument();
expect(editDefaultPolicy).not.toBeDisabled();
await userEvent.click(editDefaultPolicy);
await user.click(editDefaultPolicy);
expect(onEditPolicy).toHaveBeenCalledWith(routeTree, true);
// should not be deletable
@ -102,11 +112,11 @@ describe('Policy', () => {
const policy = within(container);
// click "more actions" and check if we can delete
await userEvent.click(policy.getByTestId('more-actions'));
await user.click(policy.getByTestId('more-actions'));
expect(screen.queryByRole('menuitem', { name: 'Edit' })).not.toBeDisabled();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeDisabled();
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
await user.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(onDeletePolicy).toHaveBeenCalled();
}
@ -133,6 +143,110 @@ describe('Policy', () => {
);
});
it('should show export option when export is allowed and supported returns true', async () => {
const onEditPolicy = jest.fn();
const onAddPolicy = jest.fn();
const onDeletePolicy = jest.fn();
const onShowAlertInstances = jest.fn(
(alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {}
);
const routeTree = mockRoutes;
const user = userEvent.setup();
renderPolicy(
<Policy
routeTree={routeTree}
currentRoute={routeTree}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onEditPolicy={onEditPolicy}
onAddPolicy={onAddPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
/>
);
// should have default policy
const defaultPolicy = screen.getByTestId('am-root-route-container');
// click "more actions"
expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument();
await user.click(within(defaultPolicy).getByTestId('more-actions'));
expect(screen.getByRole('menuitem', { name: 'Export' })).toBeInTheDocument();
});
it('should not show export option when is not supported', async () => {
const onEditPolicy = jest.fn();
const onAddPolicy = jest.fn();
const onDeletePolicy = jest.fn();
const onShowAlertInstances = jest.fn(
(alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {}
);
const routeTree = mockRoutes;
useAlertmanagerAbilitiesMock.mockReturnValue([
[true, true],
[true, true],
[false, true],
]);
const user = userEvent.setup();
renderPolicy(
<Policy
routeTree={routeTree}
currentRoute={routeTree}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onEditPolicy={onEditPolicy}
onAddPolicy={onAddPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
/>
);
// should have default policy
const defaultPolicy = screen.getByTestId('am-root-route-container');
// click "more actions"
expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument();
await user.click(within(defaultPolicy).getByTestId('more-actions'));
expect(screen.queryByRole('menuitem', { name: 'Export' })).not.toBeInTheDocument();
});
it('should not show export option when is not allowed', async () => {
const onEditPolicy = jest.fn();
const onAddPolicy = jest.fn();
const onDeletePolicy = jest.fn();
const onShowAlertInstances = jest.fn(
(alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[] | undefined) => {}
);
const routeTree = mockRoutes;
useAlertmanagerAbilitiesMock.mockReturnValue([
[true, true],
[true, true],
[true, false],
]);
const user = userEvent.setup();
renderPolicy(
<Policy
routeTree={routeTree}
currentRoute={routeTree}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onEditPolicy={onEditPolicy}
onAddPolicy={onAddPolicy}
onDeletePolicy={onDeletePolicy}
onShowAlertInstances={onShowAlertInstances}
/>
);
// should have default policy
const defaultPolicy = screen.getByTestId('am-root-route-container');
// click "more actions"
expect(within(defaultPolicy).getByTestId('more-actions')).toBeInTheDocument();
await user.click(within(defaultPolicy).getByTestId('more-actions'));
expect(screen.queryByRole('menuitem', { name: 'Export' })).not.toBeInTheDocument();
});
it('should not allow editing readOnly policy tree', () => {
const routeTree: RouteWithID = { id: '0', routes: [{ id: '1' }] };

View File

@ -1,14 +1,15 @@
import { css } from '@emotion/css';
import { uniqueId, groupBy, upperFirst, sumBy, isArray, defaults } from 'lodash';
import { defaults, groupBy, isArray, sumBy, uniqueId, upperFirst } from 'lodash';
import pluralize from 'pluralize';
import React, { FC, Fragment, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Badge, Button, Dropdown, getTagColorsFromName, Icon, Menu, Tooltip, useStyles2, Text } from '@grafana/ui';
import { Badge, Button, Dropdown, getTagColorsFromName, Icon, Menu, Text, Tooltip, useStyles2 } from '@grafana/ui';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { RouteWithID, Receiver, ObjectMatcher, AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities';
@ -16,7 +17,6 @@ import { INTEGRATION_ICONS } from '../../types/contact-points';
import { normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies';
import { createUrl } from '../../utils/url';
import { Authorize } from '../Authorize';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
@ -24,6 +24,7 @@ import { MetaText } from '../MetaText';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { Strong } from '../Strong';
import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter';
import { Matchers } from './Matchers';
import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions';
@ -125,6 +126,7 @@ const Policy: FC<PolicyComponentProps> = ({
? sumBy(matchingAlertGroups, (group) => group.alerts.length)
: undefined;
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy;
const showEditAction = updatePoliciesSupported && updatePoliciesAllowed;
const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy;
@ -149,16 +151,7 @@ const Policy: FC<PolicyComponentProps> = ({
if (showExportAction) {
dropdownMenuActions.push(
<Menu.Item
key="export-policy"
icon="download-alt"
label="Export"
url={createUrl('/api/v1/provisioning/policies/export', {
download: 'true',
format: 'yaml',
})}
target="_blank"
/>
<Menu.Item key="export-policy" icon="download-alt" label="Export" onClick={toggleShowExportDrawer} />
);
}
@ -221,7 +214,6 @@ const Policy: FC<PolicyComponentProps> = ({
</Button>
</ConditionalWrap>
</Authorize>
{dropdownMenuActions.length > 0 && (
<Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}>
<Button
@ -335,6 +327,7 @@ const Policy: FC<PolicyComponentProps> = ({
);
})}
</div>
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
</Stack>
);
};

View File

@ -1,11 +1,14 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { Link } from 'react-router-dom';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Dropdown, Icon, Menu, MenuItem, useStyles2 } from '@grafana/ui';
import { GrafanaReceiversExporter } from '../export/GrafanaReceiversExporter';
interface Props {
title: string;
description: string;
@ -13,7 +16,8 @@ interface Props {
addButtonTo: string;
className?: string;
showButton?: boolean;
exportLink?: string;
canReadSecrets?: boolean;
showExport?: boolean;
}
export const ReceiversSection = ({
@ -24,11 +28,15 @@ export const ReceiversSection = ({
addButtonTo,
children,
showButton = true,
exportLink,
canReadSecrets = false,
showExport = false,
}: React.PropsWithChildren<Props>) => {
const styles = useStyles2(getStyles);
const showMore = Boolean(exportLink);
const newMenu = <Menu>{exportLink && <MenuItem url={exportLink} label="Export all" target="_blank" />}</Menu>;
const showMore = showExport;
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const newMenu = <Menu>{showExport && <MenuItem onClick={toggleShowExportDrawer} label="Export all" />}</Menu>;
return (
<Stack direction="column" gap={2}>
<div className={cx(styles.heading, className)}>
@ -55,6 +63,7 @@ export const ReceiversSection = ({
</Stack>
</div>
{children}
{showExportDrawer && <GrafanaReceiversExporter decrypt={canReadSecrets} onClose={toggleShowExportDrawer} />}
</Stack>
);
};

View File

@ -1,6 +1,9 @@
import { screen, render, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byTestId } from 'testing-library-selector';
import { setBackendSrv } from '@grafana/runtime';
import {
@ -13,16 +16,24 @@ import { AccessControlAction, ContactPointsState, NotifierDTO, NotifierType } fr
import { backendSrv } from '../../../../../core/services/backend_srv';
import * as receiversApi from '../../api/receiversApi';
import { mockProvisioningApi, setupMswServer } from '../../mockApi';
import { enableRBAC, grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { fetchGrafanaNotifiersAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { createUrl } from '../../utils/url';
import { ReceiversTable } from './ReceiversTable';
import * as receiversMeta from './grafanaAppReceivers/useReceiversMetadata';
import { ReceiverMetadata } from './grafanaAppReceivers/useReceiversMetadata';
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 renderReceieversTable = async (
receivers: Receiver[],
notifiers: NotifierDTO[],
@ -68,6 +79,23 @@ const useGetContactPointsStateMock = jest.spyOn(receiversApi, 'useGetContactPoin
setBackendSrv(backendSrv);
const server = setupMswServer();
afterEach(() => {
server.resetHandlers();
});
const ui = {
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' }),
},
};
describe('ReceiversTable', () => {
beforeEach(() => {
jest.resetAllMocks();
@ -163,26 +191,6 @@ describe('ReceiversTable', () => {
const buttons = within(screen.getByTestId('dynamic-table')).getAllByTestId('export');
expect(buttons).toHaveLength(2);
expect(buttons).toEqual(
expect.arrayContaining([
expect.objectContaining({
href: createUrl(`http://localhost/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: 'false',
name: 'with receivers',
}),
}),
expect.objectContaining({
href: createUrl(`http://localhost/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: 'false',
name: 'no receivers',
}),
}),
])
);
});
it('should be visible when user has permissions to read provisioning with secrets', async () => {
enableRBAC();
@ -192,26 +200,6 @@ describe('ReceiversTable', () => {
const buttons = within(screen.getByTestId('dynamic-table')).getAllByTestId('export');
expect(buttons).toHaveLength(2);
expect(buttons).toEqual(
expect.arrayContaining([
expect.objectContaining({
href: createUrl(`http://localhost/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: 'true',
name: 'with receivers',
}),
}),
expect.objectContaining({
href: createUrl(`http://localhost/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: 'true',
name: 'no receivers',
}),
}),
])
);
});
it('should not be visible when user has no provisioning permissions', async () => {
enableRBAC();
@ -224,4 +212,51 @@ describe('ReceiversTable', () => {
});
});
});
describe('Exporter functionality', () => {
it('Should allow exporting receiver', async () => {
// Arrange
mockProvisioningApi(server).exportReceiver({
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
const user = userEvent.setup();
const receivers: Receiver[] = [
{
name: 'with receivers',
grafana_managed_receiver_configs: [mockGrafanaReceiver('googlechat'), mockGrafanaReceiver('sensugo')],
},
{
name: 'no receivers',
},
];
const notifiers: NotifierDTO[] = [mockNotifier('googlechat', 'Google Chat'), mockNotifier('sensugo', 'Sensu Go')];
enableRBAC();
grantUserPermissions([AccessControlAction.AlertingProvisioningRead]);
// Act
await renderReceieversTable(receivers, notifiers, GRAFANA_RULES_SOURCE_NAME);
const buttons = within(screen.getByTestId('dynamic-table')).getAllByTestId('export');
// click first export button
await user.click(buttons[0]);
const drawer = await ui.export.dialog.find();
// Assert
expect(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
});
await user.click(ui.export.jsonTab.get(drawer));
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json Export Content');
});
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument();
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument();
});
});
});

View File

@ -1,30 +1,27 @@
import pluralize from 'pluralize';
import React, { useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import { dateTime, dateTimeFormat } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Badge, Button, ConfirmModal, Icon, Modal, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { ContactPointsState, NotifiersState, ReceiversState, useDispatch } from 'app/types';
import { isOrgAdmin } from '../../../../plugins/admin/permissions';
import { useGetContactPointsState } from '../../api/receiversApi';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { deleteReceiverAction } from '../../state/actions';
import { getAlertTableStyles } from '../../styles/table';
import { SupportedPlugin } from '../../types/pluginBridges';
import { getNotificationsPermissions } from '../../utils/access-control';
import { isReceiverUsed } from '../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { extractNotifierTypeCounts } from '../../utils/receivers';
import { createUrl } from '../../utils/url';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { ProvisioningBadge } from '../Provisioning';
import { GrafanaReceiverExporter } from '../export/GrafanaReceiverExporter';
import { ActionIcon } from '../rules/ActionIcon';
import { ReceiversSection } from './ReceiversSection';
@ -65,6 +62,7 @@ function UpdateActions({ alertManagerName, receiverName, onClickDeleteReceiver }
interface ActionProps {
alertManagerName: string;
receiverName: string;
canReadSecrets?: boolean;
}
function ViewAction({ alertManagerName, receiverName }: ActionProps) {
@ -80,28 +78,26 @@ function ViewAction({ alertManagerName, receiverName }: ActionProps) {
);
}
function ExportAction({ receiverName }: ActionProps) {
const { selectedAlertmanager } = useAlertmanager();
const canReadSecrets = contextSrv.hasPermission(
getNotificationsPermissions(selectedAlertmanager ?? '').provisioning.readSecrets
);
function ExportAction({ receiverName, canReadSecrets = false }: ActionProps) {
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
return (
<Authorize actions={[AlertmanagerAction.ExportContactPoint]}>
<ActionIcon
data-testid="export"
to={createUrl(`/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: canReadSecrets.toString(),
name: receiverName,
})}
tooltip={
canReadSecrets ? 'Export contact point with decrypted secrets' : 'Export contact point with redacted secrets'
}
icon="download-alt"
target="_blank"
onClick={toggleShowExportDrawer}
/>
{showExportDrawer && (
<GrafanaReceiverExporter
receiverName={receiverName}
decrypt={canReadSecrets}
onClose={toggleShowExportDrawer}
/>
)}
</Authorize>
);
}
@ -341,33 +337,29 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
);
}, [grafanaNotifiers.result, config.alertmanager_config, receiversMetadata]);
const [createSupported, createAllowed] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
const [_, canReadSecrets] = useAlertmanagerAbility(AlertmanagerAction.DecryptSecrets);
const columns = useGetColumns(
alertManagerName,
errorStateAvailable,
contactPointsState,
configHealth,
onClickDeleteReceiver,
isVanillaAM
isVanillaAM,
canReadSecrets
);
const [createSupported, createAllowed] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
return (
<ReceiversSection
canReadSecrets={canReadSecrets}
title="Contact points"
description="Define where notifications are sent, for example, email or Slack."
showButton={createSupported && createAllowed}
addButtonLabel={'Add contact point'}
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
exportLink={
showExport
? createUrl('/api/v1/provisioning/contact-points/export', {
download: 'true',
format: 'yaml',
decrypt: isOrgAdmin().toString(),
})
: undefined
}
showExport={showExport}
>
<DynamicTable
pagination={{ itemsPerPage: 25 }}
@ -432,7 +424,8 @@ function useGetColumns(
contactPointsState: ContactPointsState | undefined,
configHealth: AlertmanagerConfigHealth,
onClickDeleteReceiver: (receiverName: string) => void,
isVanillaAM: boolean
isVanillaAM: boolean,
canReadSecrets: boolean
): RowTableColumnProps[] {
const tableStyles = useStyles2(getAlertTableStyles);
@ -507,7 +500,9 @@ function useGetColumns(
/>
)}
{(isVanillaAM || provisioned) && <ViewAction alertManagerName={alertManagerName} receiverName={name} />}
{isGrafanaAlertManager && <ExportAction alertManagerName={alertManagerName} receiverName={name} />}
{isGrafanaAlertManager && (
<ExportAction alertManagerName={alertManagerName} receiverName={name} canReadSecrets={canReadSecrets} />
)}
</div>
</Authorize>
),

View File

@ -22,6 +22,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a
false,
false,
],
"decrypt-secrets": [
false,
false,
],
"delete-contact-point": [
false,
false,
@ -119,6 +123,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi
true,
true,
],
"decrypt-secrets": [
false,
false,
],
"delete-contact-point": [
true,
true,
@ -216,6 +224,10 @@ exports[`alertmanager abilities should report everything is supported for builti
true,
false,
],
"decrypt-secrets": [
true,
false,
],
"delete-contact-point": [
true,
false,

View File

@ -28,6 +28,7 @@ export enum AlertmanagerAction {
ViewNotificationTemplate = 'view-notification-template',
UpdateNotificationTemplate = 'edit-notification-template',
DeleteNotificationTemplate = 'delete-notification-template',
DecryptSecrets = 'decrypt-secrets',
// notification policies
CreateNotificationPolicy = 'create-notification-policy',
@ -169,6 +170,10 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
ctx.hasPermission(notificationsPermissions.provisioning.read) ||
ctx.hasPermission(notificationsPermissions.provisioning.readSecrets),
],
[AlertmanagerAction.DecryptSecrets]: [
isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.provisioning.readSecrets),
],
// -- silences --
[AlertmanagerAction.CreateSilence]: [hasConfigurationAPI, ctx.hasPermission(instancePermissions.create)],
[AlertmanagerAction.ViewSilence]: [AlwaysSupported, ctx.hasPermission(instancePermissions.read)],

View File

@ -327,6 +327,13 @@ export function mockProvisioningApi(server: SetupServer) {
)
);
},
exportReceiver: (response: Record<string, string>) => {
server.use(
rest.get(`/api/v1/provisioning/contact-points/export/`, (req, res, ctx) =>
res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']))
)
);
},
};
}