mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add Rule UID and Clone button to the rule details page (#62321)
This commit is contained in:
parent
427db55204
commit
7b47beef2f
@ -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>;
|
||||
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
||||
|
@ -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};
|
||||
`,
|
||||
});
|
@ -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};
|
||||
`,
|
||||
});
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user