diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index a287ec26f4b..5ef3cf85c29 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -15,7 +15,7 @@ import { initialAsyncRequestState } from '../../utils/redux'; import { durationValidationPattern } from '../../utils/time'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; -interface Props { +interface ModalProps { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; onClose: () => void; @@ -27,7 +27,7 @@ interface FormValues { groupInterval: string; } -export function EditCloudGroupModal(props: Props): React.ReactElement { +export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const { namespace, group, onClose } = props; const styles = useStyles2(getStyles); const dispatch = useDispatch(); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx new file mode 100644 index 00000000000..8744867ab72 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx @@ -0,0 +1,11 @@ +import { reorder } from './ReorderRuleGroupModal'; + +describe('test reorder', () => { + it('should reorder arrays', () => { + const original = [1, 2, 3]; + const expected = [1, 3, 2]; + + expect(reorder(original, 1, 2)).toEqual(expected); + expect(original).not.toEqual(expected); // make sure we've not mutated the original + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx new file mode 100644 index 00000000000..439ed416cca --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx @@ -0,0 +1,230 @@ +import { css } from '@emotion/css'; +import cx from 'classnames'; +import { compact } from 'lodash'; +import React, { FC, useCallback, useState } from 'react'; +import { + DragDropContext, + Draggable, + DraggableProvided, + Droppable, + DroppableProvided, + DropResult, +} from 'react-beautiful-dnd'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Badge, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; +import { dispatch } from 'app/store/store'; +import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; + +import { updateRulesOrder } from '../../state/actions'; +import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; +import { hashRulerRule } from '../../utils/rule-id'; +import { isAlertingRule, isRecordingRule } from '../../utils/rules'; + +import { AlertStateTag } from './AlertStateTag'; + +interface ModalProps { + namespace: CombinedRuleNamespace; + group: CombinedRuleGroup; + onClose: () => void; +} + +type CombinedRuleWithUID = { uid: string } & CombinedRule; + +export const ReorderCloudGroupModal: FC = (props) => { + const { group, namespace, onClose } = props; + const [pending, setPending] = useState(false); + const [rulesList, setRulesList] = useState(group.rules); + + const styles = useStyles2(getStyles); + + const onDragEnd = useCallback( + (result: DropResult) => { + // check for no-ops so we don't update the group unless we have changes + if (!result.destination) { + return; + } + + const sameIndex = result.destination.index === result.source.index; + if (sameIndex) { + return; + } + + const newOrderedRules = reorder(rulesList, result.source.index, result.destination.index); + setRulesList(newOrderedRules); // optimistically update the new rules list + + const rulesSourceName = getRulesSourceName(namespace.rulesSource); + const rulerRules = compact(newOrderedRules.map((rule) => rule.rulerRule)); + + setPending(true); + dispatch( + updateRulesOrder({ + namespaceName: namespace.name, + groupName: group.name, + rulesSourceName: rulesSourceName, + newRules: rulerRules, + }) + ) + .unwrap() + .finally(() => { + setPending(false); + }); + }, + [group.name, namespace.name, namespace.rulesSource, rulesList] + ); + + // assign unique but stable identifiers to each (alerting / recording) rule + const rulesWithUID: CombinedRuleWithUID[] = rulesList.map((rule) => ({ + ...rule, + uid: String(hashRulerRule(rule.rulerRule!)), // TODO fix this coercion? + })); + + return ( + } + onDismiss={onClose} + onClickBackdrop={onClose} + > + + ( + + )} + > + {(droppableProvided: DroppableProvided) => ( +
+ {rulesWithUID.map((rule, index) => ( + + {(provided: DraggableProvided) => } + + ))} + {droppableProvided.placeholder} +
+ )} +
+
+
+ ); +}; + +interface ListItemProps extends React.HTMLAttributes { + provided: DraggableProvided; + rule: CombinedRule; + isClone?: boolean; + isDragging?: boolean; +} + +const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => { + const styles = useStyles2(getStyles); + + return ( +
+ {isAlertingRule(rule.promRule) && } + {isRecordingRule(rule.promRule) && } +
{rule.name}
+ +
+ ); +}; + +interface ModalHeaderProps { + namespace: CombinedRuleNamespace; + group: CombinedRuleGroup; +} + +const ModalHeader: FC = ({ namespace, group }) => { + const styles = useStyles2(getStyles); + + return ( +
+ + {isCloudRulesSource(namespace.rulesSource) && ( + + {namespace.rulesSource.meta.name} + + )} + {namespace.name} + + {group.name} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + modal: css` + max-width: 640px; + max-height: 80%; + overflow: hidden; + `, + listItem: css` + display: flex; + flex-direction: row; + align-items: center; + + gap: ${theme.spacing()}; + + background: ${theme.colors.background.primary}; + color: ${theme.colors.text.secondary}; + + border-bottom: solid 1px ${theme.colors.border.medium}; + padding: ${theme.spacing(1)} ${theme.spacing(2)}; + + &:last-child { + border-bottom: none; + } + + &.isClone { + border: solid 1px ${theme.colors.primary.shade}; + } + `, + listContainer: css` + user-select: none; + border: solid 1px ${theme.colors.border.medium}; + `, + disabled: css` + opacity: 0.5; + pointer-events: none; + `, + listItemName: css` + flex: 1; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `, + header: css` + display: flex; + align-items: center; + + gap: ${theme.spacing(1)}; + `, + dataSourceIcon: css` + width: ${theme.spacing(2)}; + height: ${theme.spacing(2)}; + `, +}); + +export function reorder(rules: T[], startIndex: number, endIndex: number): T[] { + const result = Array.from(rules); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 161b3211c32..ccf274847b6 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -19,6 +19,7 @@ import { RuleLocation } from '../RuleLocation'; import { ActionIcon } from './ActionIcon'; import { EditCloudGroupModal } from './EditRuleGroupModal'; +import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; import { RuleStats } from './RuleStats'; import { RulesTable } from './RulesTable'; @@ -38,6 +39,7 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll, const [isEditingGroup, setIsEditingGroup] = useState(false); const [isDeletingGroup, setIsDeletingGroup] = useState(false); + const [isReorderingGroup, setIsReorderingGroup] = useState(false); const [isCollapsed, setIsCollapsed] = useState(!expandAll); const { canEditRules } = useRulesAccess(); @@ -95,6 +97,17 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll, onClick={() => setIsEditingGroup(true)} /> ); + actionIcons.push( + setIsReorderingGroup(true)} + /> + ); } if (isListView) { actionIcons.push( @@ -134,6 +147,17 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll, onClick={() => setIsEditingGroup(true)} /> ); + actionIcons.push( + setIsReorderingGroup(true)} + /> + ); } actionIcons.push( @@ -194,6 +218,9 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll, {isEditingGroup && ( setIsEditingGroup(false)} /> )} + {isReorderingGroup && ( + setIsReorderingGroup(false)} /> + )} ({ rulesTable: css` margin-top: ${theme.spacing(3)}; `, + rotate90: css` + transform: rotate(90deg); + `, }); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b64a801fbdd..946118b3096 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -25,7 +25,12 @@ import { RuleWithLocation, StateHistoryItem, } from 'app/types/unified-alerting'; -import { PromApplication, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { + PostableRulerRuleGroupDTO, + PromApplication, + RulerRuleDTO, + RulerRulesConfigDTO, +} from 'app/types/unified-alerting-dto'; import { backendSrv } from '../../../../core/services/backend_srv'; import { @@ -751,6 +756,48 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( } ); +interface UpdateRulesOrderOptions { + rulesSourceName: string; + namespaceName: string; + groupName: string; + newRules: RulerRuleDTO[]; +} + +export const updateRulesOrder = createAsyncThunk( + 'unifiedalerting/updateRulesOrderForGroup', + async (options: UpdateRulesOrderOptions, thunkAPI): Promise => { + return withAppEvents( + withSerializedError( + (async () => { + const { rulesSourceName, namespaceName, groupName, newRules } = options; + + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName); + const rulesResult = await fetchRulerRules(rulerConfig); + + const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName); + if (!existingGroup) { + throw new Error(`Group "${groupName}" not found.`); + } + + const payload: PostableRulerRuleGroupDTO = { + name: existingGroup.name, + interval: existingGroup.interval, + rules: newRules, + }; + + await setRulerRuleGroup(rulerConfig, namespaceName, payload); + + await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName })); + })() + ), + { + errorMessage: 'Failed to update namespace / group', + successMessage: 'Update successful', + } + ); + } +); + export const addExternalAlertmanagersAction = createAsyncThunk( 'unifiedAlerting/addExternalAlertmanagers', async (alertmanagerConfig: ExternalAlertmanagerConfig, thunkAPI): Promise => { diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.ts.snap new file mode 100644 index 00000000000..1e03e1424dd --- /dev/null +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`hashRulerRule should hash alerting rule 1`] = `"1465866290"`; + +exports[`hashRulerRule should hash recording rules 1`] = `"2044193757"`; diff --git a/public/app/features/alerting/unified/utils/rule-id.test.ts b/public/app/features/alerting/unified/utils/rule-id.test.ts new file mode 100644 index 00000000000..da4f98326b2 --- /dev/null +++ b/public/app/features/alerting/unified/utils/rule-id.test.ts @@ -0,0 +1,62 @@ +import { + GrafanaAlertStateDecision, + GrafanaRuleDefinition, + RulerAlertingRuleDTO, + RulerGrafanaRuleDTO, + RulerRecordingRuleDTO, +} from 'app/types/unified-alerting-dto'; + +import { hashRulerRule } from './rule-id'; + +describe('hashRulerRule', () => { + it('should not hash unknown rule types', () => { + const unknownRule = {}; + + expect(() => { + // @ts-ignore + hashRulerRule(unknownRule); + }).toThrowError(); + }); + it('should hash recording rules', () => { + const recordingRule: RulerRecordingRuleDTO = { + record: 'instance:node_num_cpu:sum', + expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))', + labels: { type: 'cpu' }, + }; + + expect(hashRulerRule(recordingRule)).toMatchSnapshot(); + }); + + it('should hash alerting rule', () => { + const alertingRule: RulerAlertingRuleDTO = { + alert: 'always-alerting', + expr: 'vector(20) > 7', + labels: { type: 'cpu' }, + annotations: { description: 'CPU usage too high' }, + }; + + expect(hashRulerRule(alertingRule)).toMatchSnapshot(); + }); + + it('should hash Grafana Managed rules', () => { + const RULE_UID = 'abcdef12345'; + const grafanaAlertDefinition: GrafanaRuleDefinition = { + uid: RULE_UID, + namespace_uid: 'namespace', + namespace_id: 0, + title: 'my rule', + condition: '', + data: [], + no_data_state: GrafanaAlertStateDecision.NoData, + exec_err_state: GrafanaAlertStateDecision.Alerting, + }; + const grafanaRule: RulerGrafanaRuleDTO = { + grafana_alert: grafanaAlertDefinition, + for: '30s', + labels: { type: 'cpu' }, + annotations: { description: 'CPU usage too high' }, + }; + + expect(hashRulerRule(grafanaRule)).toBe(RULE_UID); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 8a8a355063b..af8a005041b 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -107,11 +107,11 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier { const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unesacapeDollars); if (prefix === cloudRuleIdentifierPrefix) { - return { ruleSourceName, namespace, groupName, rulerRuleHash: Number(hash) }; + return { ruleSourceName, namespace, groupName, rulerRuleHash: hash }; } if (prefix === prometheusRuleIdentifierPrefix) { - return { ruleSourceName, namespace, groupName, ruleHash: Number(hash) }; + return { ruleSourceName, namespace, groupName, ruleHash: hash }; } } @@ -173,10 +173,10 @@ function hash(value: string): number { return hash; } -// this is used to identify lotex rules, as they do not have a unique identifier -function hashRulerRule(rule: RulerRuleDTO): number { +// this is used to identify rules, mimir / loki rules do not have a unique identifier +export function hashRulerRule(rule: RulerRuleDTO): string { if (isRecordingRulerRule(rule)) { - return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)])); + return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)])).toString(); } else if (isAlertingRulerRule(rule)) { return hash( JSON.stringify([ @@ -185,15 +185,17 @@ function hashRulerRule(rule: RulerRuleDTO): number { hashLabelsOrAnnotations(rule.annotations), hashLabelsOrAnnotations(rule.labels), ]) - ); + ).toString(); + } else if (isGrafanaRulerRule(rule)) { + return rule.grafana_alert.uid; } else { throw new Error('only recording and alerting ruler rules can be hashed'); } } -function hashRule(rule: Rule): number { +function hashRule(rule: Rule): string { if (isRecordingRule(rule)) { - return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])); + return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])).toString(); } if (isAlertingRule(rule)) { @@ -204,7 +206,7 @@ function hashRule(rule: Rule): number { hashLabelsOrAnnotations(rule.annotations), hashLabelsOrAnnotations(rule.labels), ]) - ); + ).toString(); } throw new Error('only recording and alerting rules can be hashed'); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 615137312bf..3a2aa8b8884 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -35,8 +35,8 @@ export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { return typeof rule === 'object' && rule.type === PromRuleType.Alerting; } -export function isRecordingRule(rule: Rule): rule is RecordingRule { - return rule.type === PromRuleType.Recording; +export function isRecordingRule(rule: Rule | undefined): rule is RecordingRule { + return typeof rule === 'object' && rule.type === PromRuleType.Recording; } export function isAlertingRulerRule(rule?: RulerRuleDTO): rule is RulerAlertingRuleDTO { diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index e8e0427ad94..13037fd637b 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -122,7 +122,7 @@ export interface CloudRuleIdentifier { ruleSourceName: string; namespace: string; groupName: string; - rulerRuleHash: number; + rulerRuleHash: string; } export interface GrafanaRuleIdentifier { ruleSourceName: 'grafana'; @@ -134,7 +134,7 @@ export interface PrometheusRuleIdentifier { ruleSourceName: string; namespace: string; groupName: string; - ruleHash: number; + ruleHash: string; } export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier | PrometheusRuleIdentifier;