Alerting: Add Rule UID and Clone button to the rule details page (#62321)

This commit is contained in:
Konrad Lalik 2023-01-30 16:02:01 +01:00 committed by GitHub
parent 427db55204
commit 7b47beef2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 188 additions and 75 deletions

View File

@ -15,7 +15,7 @@ import { CombinedRule } from 'app/types/unified-alerting';
import { RuleViewer } from './RuleViewer';
import { useCombinedRule } from './hooks/useCombinedRule';
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { getCloudRule, getGrafanaRule } from './mocks';
import { getCloudRule, getGrafanaRule, grantUserPermissions } from './mocks';
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' });
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
@ -61,6 +61,7 @@ const renderRuleViewer = () => {
const ui = {
actionButtons: {
edit: byRole('link', { name: /edit/i }),
clone: byRole('link', { name: /clone/i }),
delete: byRole('button', { name: /delete/i }),
silence: byRole('link', { name: 'Silence' }),
},
@ -200,6 +201,36 @@ describe('RuleDetails RBAC', () => {
// Assert
expect(ui.actionButtons.silence.query()).toBeInTheDocument();
});
it('Should render clone button for users having create rule permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false,
dispatched: true,
});
grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
await renderRuleViewer();
expect(ui.actionButtons.clone.get()).toBeInTheDocument();
});
it('Should NOT render clone button for users without create rule permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false,
dispatched: true,
});
const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction;
grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]);
await renderRuleViewer();
expect(ui.actionButtons.clone.query()).not.toBeInTheDocument();
});
});
describe('Cloud rules action buttons', () => {
let mockCombinedRule: jest.MockedFn<typeof useCombinedRule>;

View File

@ -10,6 +10,7 @@ import {
Button,
Collapse,
Icon,
IconButton,
LoadingPlaceholder,
useStyles2,
VerticalGroup,
@ -18,7 +19,7 @@ import {
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
import { AlertQuery } from '../../../types/unified-alerting-dto';
import { AlertQuery, GrafanaRuleDefinition } from '../../../types/unified-alerting-dto';
import { GrafanaRuleQueryViewer, QueryPreview } from './GrafanaRuleQueryViewer';
import { AlertLabels } from './components/AlertLabels';
@ -181,7 +182,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
)}
{!!rule.labels && !!Object.keys(rule.labels).length && (
<DetailsField label="Labels" horizontal={true}>
<AlertLabels labels={rule.labels} />
<AlertLabels labels={rule.labels} className={styles.labels} />
</DetailsField>
)}
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
@ -190,7 +191,10 @@ export function RuleViewer({ match }: RuleViewerProps) {
<div className={styles.rightSide}>
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
<DetailsField label="Namespace / Group">{`${rule.namespace.name} / ${rule.group.name}`}</DetailsField>
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
{rule.namespace.name} / {rule.group.name}
</DetailsField>
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
</div>
</div>
<div>
@ -244,6 +248,17 @@ export function RuleViewer({ match }: RuleViewerProps) {
);
}
function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
const styles = useStyles2(getStyles);
const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid);
return (
<DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}>
{rule.uid} <IconButton name="copy" onClick={copyUID} />
</DetailsField>
);
}
function isLoading(data: Record<string, PanelData>): boolean {
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
}
@ -278,13 +293,26 @@ const getStyles = (theme: GrafanaTheme2) => {
details: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(4)};
`,
leftSide: css`
flex: 1;
`,
rightSide: css`
padding-left: 90px;
width: 300px;
padding-right: ${theme.spacing(3)};
`,
rightSideDetails: css`
& > div:first-child {
width: auto;
}
`,
labels: css`
justify-content: flex-start;
`,
ruleUid: css`
display: flex;
align-items: center;
gap: ${theme.spacing(1)};
`,
};
};

View File

@ -17,6 +17,7 @@ If the item needs more rationale and you feel like a single sentence is inedequa
## Refactoring
- Get rid of "+ Add new" in drop-downs : Let's see if is there a way we can make it work with `<Select allowCustomValue />`
- There is a lot of overlap between `RuleActionButtons` and `RuleDetailsActionButtons`. As these components contain a lot of logic it would be nice to extract that logic into hoooks
## Bug fixes

View File

@ -2,13 +2,16 @@ import React from 'react';
import { TagList } from '@grafana/ui';
type Props = { labels: Record<string, string>; className?: string };
interface Props {
labels: Record<string, string>;
className?: string;
}
export const AlertLabels = ({ labels, className }: Props) => {
const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__')));
return (
<div className={className}>
<TagList tags={Object.values(pairs).map(([label, value]) => `${label}=${value}`)} />
<TagList tags={Object.values(pairs).map(([label, value]) => `${label}=${value}`)} className={className} />
</div>
);
};

View File

@ -8,15 +8,22 @@ interface Props {
label: React.ReactNode;
className?: string;
horizontal?: boolean;
childrenWrapperClassName?: string;
}
export const DetailsField = ({ className, label, horizontal, children }: React.PropsWithChildren<Props>) => {
export const DetailsField = ({
className,
label,
horizontal,
children,
childrenWrapperClassName,
}: React.PropsWithChildren<Props>) => {
const styles = useStyles2(getStyles);
return (
<div className={cx(className, styles.field, horizontal ? styles.fieldHorizontal : styles.fieldVertical)}>
<div className={cx(styles.field, horizontal ? styles.fieldHorizontal : styles.fieldVertical, className)}>
<div>{label}</div>
<div>{children}</div>
<div className={childrenWrapperClassName}>{children}</div>
</div>
);
};

View File

@ -30,11 +30,7 @@ export const AlertGroup = ({ alertManagerSourceName, group }: Props) => {
onToggle={() => setIsCollapsed(!isCollapsed)}
data-testid="alert-group-collapse-toggle"
/>
{Object.keys(group.labels).length ? (
<AlertLabels className={styles.headerLabels} labels={group.labels} />
) : (
<span>No grouping</span>
)}
{Object.keys(group.labels).length ? <AlertLabels labels={group.labels} /> : <span>No grouping</span>}
</div>
<AlertGroupHeader group={group} />
</div>
@ -49,10 +45,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-top: ${theme.spacing(2)};
}
`,
headerLabels: css`
padding-bottom: 0 !important;
margin-bottom: -${theme.spacing(0.5)};
`,
header: css`
display: flex;
flex-direction: row;

View File

@ -47,7 +47,7 @@ export const AlertGroupAlertsTable = ({ alerts, alertManagerSourceName }: Props)
id: 'labels',
label: 'Labels',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { labels } }) => <AlertLabels className={styles.labels} labels={labels} />,
renderCell: ({ data: { labels } }) => <AlertLabels labels={labels} />,
size: 1,
},
],
@ -88,7 +88,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-left: ${theme.spacing(1)};
font-size: ${theme.typography.bodySmall.fontSize};
`,
labels: css`
padding-bottom: 0;
`,
});

View File

@ -0,0 +1,73 @@
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';
import { createUrl } from '../../utils/url';
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 [provRuleCloneUrl, setProvRuleCloneUrl] = useState<string | undefined>(undefined);
const styles = useStyles2(getStyles);
const cloneUrl = createUrl('/alerting/new', { copyFrom: ruleId.stringifyIdentifier(ruleIdentifier) });
return (
<>
<LinkButton
title="Clone"
className={className}
size="sm"
key="clone"
variant="secondary"
icon="copy"
href={isProvisioned ? undefined : cloneUrl}
onClick={isProvisioned ? () => setProvRuleCloneUrl(cloneUrl) : undefined}
ref={ref}
>
{text}
</LinkButton>
<ConfirmModal
isOpen={!!provRuleCloneUrl}
title="Clone provisioned 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 cloned rule because the original one has been provisioned
and cannot be used for rules created in the UI.
</p>
</div>
}
confirmText="Clone"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onDismiss={() => setProvRuleCloneUrl(undefined)}
/>
</>
);
}
);
CloneRuleButton.displayName = 'CloneRuleButton';
const getStyles = (theme: GrafanaTheme2) => ({
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
});

View File

@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, locationService } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { Button, ClipboardButton, ConfirmModal, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useDispatch } from 'app/types';
@ -18,6 +18,7 @@ import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
import { CloneRuleButton } from './CloneRuleButton';
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
interface Props {
@ -32,7 +33,6 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
const style = useStyles2(getStyles);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const [provRuleCloneUrl, setProvRuleCloneUrl] = useState<string | undefined>(undefined);
const rulesSourceName = getRulesSourceName(rulesSource);
@ -128,21 +128,9 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
);
}
const cloneUrl = createUrl('/alerting/new', { copyFrom: ruleId.stringifyIdentifier(identifier) });
// For provisioned rules an additional confirmation step is required
// Users have to be aware that the cloned rule will NOT be marked as provisioned
buttons.push(
<Tooltip placement="top" content="Duplicate">
<LinkButton
title="Duplicate"
className={style.button}
size="sm"
key="clone"
variant="secondary"
icon="copy"
href={isProvisioned ? undefined : cloneUrl}
onClick={isProvisioned ? () => setProvRuleCloneUrl(cloneUrl) : undefined}
/>
<Tooltip placement="top" content="Clone">
<CloneRuleButton ruleIdentifier={identifier} isProvisioned={isProvisioned} className={style.button} />
</Tooltip>
);
}
@ -183,24 +171,6 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
onDismiss={() => setRuleToDelete(undefined)}
/>
)}
<ConfirmModal
isOpen={!!provRuleCloneUrl}
title="Clone provisioned rule"
body={
<div>
<p>
The new rule will <span className={style.bold}>NOT</span> be marked as a provisioned rule.
</p>
<p>
You will need to set a new alert group for the cloned rule because the original one has been provisioned
and cannot be used for rules created in the UI.
</p>
</div>
}
confirmText="Clone"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onDismiss={() => setProvRuleCloneUrl(undefined)}
/>
</>
);
}
@ -216,7 +186,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({
button: css`
padding: 0 ${theme.spacing(2)};
`,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
});

View File

@ -14,6 +14,7 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { deleteRuleAction } from '../../state/actions';
import { getRulesPermissions } from '../../utils/access-control';
import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
@ -22,6 +23,8 @@ import * as ruleId from '../../utils/rule-id';
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { DeclareIncident } from '../bridges/DeclareIncidentButton';
import { CloneRuleButton } from './CloneRuleButton';
interface Props {
rule: CombinedRule;
rulesSource: RulesSource;
@ -78,6 +81,8 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing;
const rulesPermissions = getRulesPermissions(rulesSourceName);
const hasCreateRulePermission = contextSrv.hasPermission(rulesPermissions.create);
const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule);
const returnTo = location.pathname + location.search;
@ -177,17 +182,11 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
);
}
if (isViewMode) {
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
if (isViewMode && rulerRule) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
const editURL = urlUtil.renderUrl(
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
{
returnTo,
}
);
if (isEditable && !isFederated) {
rightButtons.push(
<ClipboardButton
key="copy"
@ -202,14 +201,29 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
</ClipboardButton>
);
if (!isProvisioned) {
const editURL = urlUtil.renderUrl(
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
{
returnTo,
}
);
rightButtons.push(
<LinkButton size="sm" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
}
}
if (hasCreateRulePermission && !isFederated) {
rightButtons.push(
<LinkButton size="sm" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
<CloneRuleButton key="clone" text="Clone" ruleIdentifier={identifier} isProvisioned={isProvisioned} />
);
}
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
if (isRemovable && !isFederated && !isProvisioned) {
rightButtons.push(
<Button
size="sm"