Alerting: Add delete bulk action in the alert list view (#104611)
* Add pause/unpause bulk actions button in the alert list view * add delete bulk action * Add tracking and refactor FolderActionMenuItem * update translations and text * update text * add finally * use ability for delete action * don't show bulk actions if no action is allowed * invalidate tags for delete action * revert invalidating and redirect to list page instead * redirect when pausing/unpausing * add pause/unpause endpoints * add translations * add folder name in delete modal * address pr review * disable pause/unpause * update redirect * remove unnecessary prop * rename FolderActionMenuItem component to PauseUnpauseActionMenuItem * address review comments * fetch rules before redirecting in list view 1 --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> Co-authored-by: Mariell Hoversholm <mariell.hoversholm@grafana.com>
This commit is contained in:
co-authored by
Gilles De Mey
Mariell Hoversholm
parent
3c904c701d
commit
277d271476
@@ -299,6 +299,30 @@ export function trackUseCentralHistoryMaxEventsReached(payload: { from: number;
|
||||
reportInteraction('grafana_alerting_central_alert_state_history_max_events_reached', payload);
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsDeleteSuccess() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_delete_success');
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsDeleteFail() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_delete_fail');
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsPauseSuccess() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_pause_success');
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsUnpauseSuccess() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_unpause_success');
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsPauseFail() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_pause_fail');
|
||||
}
|
||||
|
||||
export function trackFolderBulkActionsUnpauseFail() {
|
||||
reportInteraction('grafana_alerting_folder_bulk_actions_unpause_fail');
|
||||
}
|
||||
|
||||
export type AlertRuleTrackingProps = {
|
||||
user_id: number;
|
||||
grafana_version?: string;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { WithNotificationOptions, alertingApi } from './alertingApi';
|
||||
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
|
||||
import { rulerUrlBuilder } from './ruler';
|
||||
|
||||
export const alertingFolderActionsApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
pauseFolder: build.mutation<void, WithNotificationOptions<{ namespace: string }>>({
|
||||
query: ({ namespace, notificationOptions }) => {
|
||||
const successMessage = t(
|
||||
'alerting.bulk-actions.pause.success',
|
||||
'Rules evaluation successfully paused for folder'
|
||||
);
|
||||
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace);
|
||||
|
||||
return {
|
||||
url: path,
|
||||
params,
|
||||
body: {
|
||||
is_paused: true,
|
||||
},
|
||||
method: 'PATCH',
|
||||
notificationOptions: {
|
||||
successMessage,
|
||||
...notificationOptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
unpauseFolder: build.mutation<void, WithNotificationOptions<{ namespace: string }>>({
|
||||
query: ({ namespace, notificationOptions }) => {
|
||||
const successMessage = t(
|
||||
'alerting.bulk-actions.unpause.success',
|
||||
'Rules successfully unpaused for this folder'
|
||||
);
|
||||
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace);
|
||||
|
||||
return {
|
||||
url: path,
|
||||
params,
|
||||
body: {
|
||||
is_paused: false,
|
||||
},
|
||||
method: 'PATCH',
|
||||
notificationOptions: {
|
||||
successMessage,
|
||||
...notificationOptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
deleteGrafanaRulesFromFolder: build.mutation<void, WithNotificationOptions<{ namespace: string }>>({
|
||||
query: ({ namespace, notificationOptions }) => {
|
||||
const successMessage = t('alerting.bulk-actions.delete.success', 'Rules successfully deleted from folder');
|
||||
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace);
|
||||
|
||||
return {
|
||||
url: path,
|
||||
params,
|
||||
method: 'DELETE',
|
||||
notificationOptions: {
|
||||
successMessage,
|
||||
...notificationOptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -4,18 +4,21 @@ import { Button, ButtonProps, Icon, Stack } from '@grafana/ui';
|
||||
|
||||
import { Trans, t } from '../../../../core/internationalization';
|
||||
|
||||
const MoreButton = forwardRef(function MoreButton(props: ButtonProps, ref: Ref<HTMLButtonElement>) {
|
||||
const MoreButton = forwardRef(function MoreButton(
|
||||
props: ButtonProps & { title?: string },
|
||||
ref: Ref<HTMLButtonElement>
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-label={t('alerting.more-button.aria-label', 'More')}
|
||||
aria-label={props.title ?? t('alerting.more-button.aria-label', 'More')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0}>
|
||||
<Trans i18nKey="alerting.more-button.button-text">More</Trans>
|
||||
{props.title ?? <Trans i18nKey="alerting.more-button.button-text">More</Trans>}
|
||||
<Icon name="angle-down" />
|
||||
</Stack>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ConfirmModal, Space, Text } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { trackFolderBulkActionsDeleteFail, trackFolderBulkActionsDeleteSuccess } from '../../Analytics';
|
||||
|
||||
export interface Props {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
export const DeleteModal = React.memo(({ onConfirm, onDismiss, isOpen, folderName }: Props) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const onDeleteConfirm = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
trackFolderBulkActionsDeleteSuccess();
|
||||
onDismiss();
|
||||
} catch {
|
||||
trackFolderBulkActionsDeleteFail();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
body={
|
||||
<>
|
||||
<Text element="p">
|
||||
<Trans i18nKey="alerting.folder-bulk-actions.delete-modal-text" values={{ folderName: folderName }}>
|
||||
This action will delete all alert rules in the <code>{'{{folderName}}'}</code> folder. Nested folders will
|
||||
not be affected.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Space v={2} />
|
||||
</>
|
||||
}
|
||||
confirmationText={t('alerting.folder-bulk-actions.delete-modal-confirmation-text', 'Delete')}
|
||||
confirmText={
|
||||
isDeleting
|
||||
? t('alerting.folder-bulk-actions.delete-modal-deleting', 'Deleting...')
|
||||
: t('alerting.folder-bulk-actions.delete-modal-delete-button', 'Delete')
|
||||
}
|
||||
onDismiss={onDismiss}
|
||||
onConfirm={onDeleteConfirm}
|
||||
title={t('alerting.folder-bulk-actions.delete-modal-title', 'Delete')}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Dropdown, IconButton, Menu } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { alertingFolderActionsApi } from '../../api/alertingFolderActionsApi';
|
||||
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
||||
import { FolderBulkAction, useFolderBulkActionAbility } from '../../hooks/useAbilities';
|
||||
import { useFolder } from '../../hooks/useFolder';
|
||||
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../../state/actions';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { createRelativeUrl } from '../../utils/url';
|
||||
|
||||
import { DeleteModal } from './DeleteModal';
|
||||
import { PauseUnpauseActionMenuItem } from './PauseUnpauseActionMenuItem';
|
||||
interface Props {
|
||||
folderUID: string;
|
||||
}
|
||||
|
||||
export const FolderBulkActionsButton = ({ folderUID }: Props) => {
|
||||
const [pauseSupported, pauseAllowed] = useFolderBulkActionAbility(FolderBulkAction.Pause);
|
||||
const canPause = pauseSupported && pauseAllowed && false; // lets disable pause for now
|
||||
const [deleteSupported, deleteAllowed] = useFolderBulkActionAbility(FolderBulkAction.Delete);
|
||||
const canDelete = deleteSupported && deleteAllowed;
|
||||
const [pauseFolder, updateState] = alertingFolderActionsApi.endpoints.pauseFolder.useMutation();
|
||||
const [unpauseFolder, unpauseState] = alertingFolderActionsApi.endpoints.unpauseFolder.useMutation();
|
||||
const [deleteGrafanaRulesFromFolder, deleteState] =
|
||||
alertingFolderActionsApi.endpoints.deleteGrafanaRulesFromFolder.useMutation();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const folderName = useFolder(folderUID).folder?.title || 'unknown folder';
|
||||
const listView2Enabled = config.featureToggles.alertingListViewV2 ?? false;
|
||||
const view = listView2Enabled ? 'list' : 'grouped';
|
||||
const redirectToListView = useRedirectToListView(view);
|
||||
|
||||
if (!canPause && !canDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
await deleteGrafanaRulesFromFolder({ namespace: folderUID }).unwrap();
|
||||
await redirectToListView();
|
||||
};
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{canPause && (
|
||||
<>
|
||||
<PauseUnpauseActionMenuItem
|
||||
folderUID={folderUID}
|
||||
action="pause"
|
||||
executeAction={async (folderUID) => {
|
||||
await pauseFolder({ namespace: folderUID }).unwrap();
|
||||
await redirectToListView();
|
||||
}}
|
||||
isLoading={updateState.isLoading}
|
||||
/>
|
||||
<PauseUnpauseActionMenuItem
|
||||
folderUID={folderUID}
|
||||
action="unpause"
|
||||
executeAction={async (folderUID) => {
|
||||
await unpauseFolder({ namespace: folderUID }).unwrap();
|
||||
await redirectToListView();
|
||||
}}
|
||||
isLoading={unpauseState.isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Menu.Item
|
||||
label={t('alerting.folder-bulk-actions.delete.button.label', 'Delete rules')}
|
||||
icon="trash-alt"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={deleteState.isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown overlay={<Menu>{menuItems}</Menu>}>
|
||||
<IconButton
|
||||
name="ellipsis-v"
|
||||
size="sm"
|
||||
aria-label={t('alerting.folder-bulk-actions.more-button.title', 'Folder bulk Actions')}
|
||||
tooltip={t('alerting.folder-bulk-actions.more-button.tooltip', 'Folder bulk Actions')}
|
||||
/>
|
||||
</Dropdown>
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onConfirm={onConfirmDelete}
|
||||
onDismiss={() => setIsDeleteModalOpen(false)}
|
||||
folderName={folderName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useRedirectToListView(view: string) {
|
||||
const dispatch = useDispatch();
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
const redirectToListView = async () => {
|
||||
if (prometheusRulesPrimary) {
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
|
||||
await dispatch(fetchAllPromRulesAction(false));
|
||||
} else {
|
||||
await dispatch(fetchAllPromAndRulerRulesAction(false));
|
||||
}
|
||||
locationService.push(createRelativeUrl('/alerting/list', { view }, { skipSubPath: true }));
|
||||
};
|
||||
|
||||
return redirectToListView;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { Menu } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import {
|
||||
trackFolderBulkActionsPauseFail,
|
||||
trackFolderBulkActionsPauseSuccess,
|
||||
trackFolderBulkActionsUnpauseFail,
|
||||
trackFolderBulkActionsUnpauseSuccess,
|
||||
} from '../../Analytics';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
interface Props {
|
||||
folderUID: string;
|
||||
action: 'pause' | 'unpause';
|
||||
executeAction: (folderUID: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
export function PauseUnpauseActionMenuItem({ folderUID, executeAction, isLoading, action }: Props) {
|
||||
const notifyApp = useAppNotification();
|
||||
const label =
|
||||
action === 'pause'
|
||||
? t('alerting.folder-bulk-actions.pause.button.label', 'Pause all rule evaluation')
|
||||
: t('alerting.folder-bulk-actions.unpause.button.label', 'Resume all rule evaluation');
|
||||
const icon = action === 'pause' ? 'pause' : 'play';
|
||||
const trackActionSuccess =
|
||||
action === 'pause' ? trackFolderBulkActionsPauseSuccess : trackFolderBulkActionsUnpauseSuccess;
|
||||
const trackActionFail = action === 'pause' ? trackFolderBulkActionsPauseFail : trackFolderBulkActionsUnpauseFail;
|
||||
const onActionClick = async () => {
|
||||
try {
|
||||
await executeAction(folderUID);
|
||||
trackActionSuccess();
|
||||
} catch (error) {
|
||||
trackActionFail();
|
||||
notifyApp.error(
|
||||
t('alerting.folder-bulk-actions.error', 'Failed to execute action for folder: {{error}}', {
|
||||
error: stringifyErrorLike(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <Menu.Item label={label} icon={icon} disabled={isLoading} onClick={onActionClick} />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Badge, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace, RulesSource } from 'app/types/unified-alerting';
|
||||
@@ -18,6 +19,7 @@ import { CollapseToggle } from '../CollapseToggle';
|
||||
import { RuleLocation } from '../RuleLocation';
|
||||
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
|
||||
import { decodeGrafanaNamespace } from '../expressions/util';
|
||||
import { FolderBulkActionsButton } from '../folder-bulk-actions/FolderBulkActionsButton';
|
||||
|
||||
import { ActionIcon } from './ActionIcon';
|
||||
import { RuleGroupStats } from './RuleStats';
|
||||
@@ -67,6 +69,8 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
|
||||
const canEditGroup = hasRuler && !isProvisioned && !isFederated && !isPluginProvided && canEditRules(rulesSourceName);
|
||||
|
||||
const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI;
|
||||
|
||||
// check what view mode we are in
|
||||
const isListView = viewMode === 'list';
|
||||
const isGroupView = viewMode === 'grouped';
|
||||
@@ -145,6 +149,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
onClick={() => setIsExporting('folder')}
|
||||
/>
|
||||
);
|
||||
if (isFolderBulkActionsEnabled && folderUID && isListView) {
|
||||
actionIcons.push(<FolderBulkActionsButton folderUID={folderUID} key="folder-bulk-actions" />);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ export enum AlertRuleAction {
|
||||
DeletePermanently = 'delete-alert-rule-permanently',
|
||||
}
|
||||
|
||||
// this enum list all of the bulk actions we can perform on a folder
|
||||
export enum FolderBulkAction {
|
||||
Pause = 'pause-folder', // unpause permissions are the same as pause
|
||||
Delete = 'delete-folder',
|
||||
}
|
||||
|
||||
// this enum lists all of the actions we can perform within alerting in general, not linked to a specific
|
||||
// alert source, rule or alertmanager
|
||||
export enum AlertingAction {
|
||||
@@ -113,10 +119,25 @@ export enum AlertingAction {
|
||||
const AlwaysSupported = true;
|
||||
const NotSupported = false;
|
||||
|
||||
export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction;
|
||||
export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction;
|
||||
export type Ability = [actionSupported: boolean, actionAllowed: boolean];
|
||||
export type Abilities<T extends Action> = Record<T, Ability>;
|
||||
|
||||
/**
|
||||
* This one will check for folder abilities
|
||||
*/
|
||||
export const useFolderBulkActionAbilities = (): Abilities<FolderBulkAction> => {
|
||||
return {
|
||||
[FolderBulkAction.Pause]: [AlwaysSupported, isAdmin()],
|
||||
[FolderBulkAction.Delete]: [AlwaysSupported, isAdmin()],
|
||||
};
|
||||
};
|
||||
|
||||
export const useFolderBulkActionAbility = (action: FolderBulkAction): Ability => {
|
||||
const allAbilities = useFolderBulkActionAbilities();
|
||||
return allAbilities[action];
|
||||
};
|
||||
|
||||
/**
|
||||
* This one will check for alerting abilities that don't apply to any particular alert source or alert rule
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { FolderBulkActionsButton } from '../components/folder-bulk-actions/FolderBulkActionsButton';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { groups } from '../utils/navigation';
|
||||
|
||||
@@ -42,6 +44,8 @@ export function PaginatedGrafanaLoader() {
|
||||
|
||||
const groupsByFolder = useMemo(() => groupBy(groupsPage, 'folderUid'), [groupsPage]);
|
||||
|
||||
const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI;
|
||||
|
||||
return (
|
||||
<DataSourceSection name="Grafana" application="grafana" uid={GrafanaRulesSourceSymbol} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
@@ -59,6 +63,7 @@ export function PaginatedGrafanaLoader() {
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
actions={isFolderBulkActionsEnabled ? <FolderBulkActionsButton folderUID={folderUid} /> : null}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<GrafanaRuleGroupListItem
|
||||
|
||||
@@ -618,6 +618,17 @@
|
||||
"link-dashboard-and-panel": "Link dashboard and panel",
|
||||
"placeholder-value-input-default": "Enter custom annotation content..."
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Rules successfully deleted from folder"
|
||||
},
|
||||
"pause": {
|
||||
"success": "Rules evaluation successfully paused for folder"
|
||||
},
|
||||
"unpause": {
|
||||
"success": "Rules successfully unpaused for this folder"
|
||||
}
|
||||
},
|
||||
"central-alert-history": {
|
||||
"details": {
|
||||
"error": "Error loading rule for this event.",
|
||||
@@ -1031,6 +1042,33 @@
|
||||
"filter-view-results": {
|
||||
"aria-label-filteredrulelist": "filtered-rule-list"
|
||||
},
|
||||
"folder-bulk-actions": {
|
||||
"delete": {
|
||||
"button": {
|
||||
"label": "Delete rules"
|
||||
}
|
||||
},
|
||||
"delete-modal-confirmation-text": "Delete",
|
||||
"delete-modal-delete-button": "Delete",
|
||||
"delete-modal-deleting": "Deleting...",
|
||||
"delete-modal-text": "This action will delete all alert rules in the <1>{{folderName}}</1> folder. Nested folders will not be affected.",
|
||||
"delete-modal-title": "Delete",
|
||||
"error": "Failed to execute action for folder: {{error}}",
|
||||
"more-button": {
|
||||
"title": "Folder bulk Actions",
|
||||
"tooltip": "Folder bulk Actions"
|
||||
},
|
||||
"pause": {
|
||||
"button": {
|
||||
"label": "Pause all rule evaluation"
|
||||
}
|
||||
},
|
||||
"unpause": {
|
||||
"button": {
|
||||
"label": "Resume all rule evaluation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"folder-selector": {
|
||||
"description-select-folder": "Select a folder to store your rule in."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user