mirror of https://github.com/grafana/grafana
Alerting: Enhance Ruler and Prometheus group synchronization (#99012)
* Add group actions menu * Refactor modals to accept raw ruler group * Use prometheus and ruler responses to dispaly GMA rules in the hierarchical view * Add groups loader component for data source managed rules * Improve rules matching algorithm for the search results * Use plus and minus icons for reconciliation state * loading spinner WIP for operations / transactions * update comment * Use ruler rules order when displaying a group, change rurler preload behaviour * Add ruler-based ordering for GMA rules * Refactor ruler API mocking * Refactor rule components to accept ruler only rules * Add tests for GrafanaGroupLoader * Add tests for vanilla prom groups * Unify data matching code, add tests for DS groups loader * Fix errors after rebasing * Improve handling of ruler group absence * Fix cache key * Add group action buttons for the new group pages * Add new rule action buttons to the new list page * Address PR feeback, component renaming, missing translations * Unify groups and rules links and actions * Fix new rule button * Add rule list action buttons tests * Fix lint errors * Add redirect to rule details page on save * Update FilterView tests * Fix imports and remove unused code * Improve type definitions, add pooling to Prom hooks, add inline docs * Remove unused code of group modals * Update translations * Disable cache population for filter-view generators * Add consistency check Alert to the RuleViewer when V2 list is enabled * Disable UI errors in prom generator * Improve missing datasouce handling * Add missing translations * Improve group loader tests, remove unused code * Enhance Prometheus API query to include notification options * Improve error handling, remove consistency check for vanilla prom data sources * Address PR feedback, add new version of the useHasRuler hook --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/102901/merge
parent
80fb267cd5
commit
3e15459d20
@ -0,0 +1,31 @@ |
||||
export interface GrafanaGroupUpdatedResponse { |
||||
message: string; |
||||
/** |
||||
* UIDs of rules created from this request |
||||
*/ |
||||
created?: string[]; |
||||
/** |
||||
* UIDs of rules updated from this request |
||||
*/ |
||||
updated?: string[]; |
||||
} |
||||
|
||||
export interface CloudGroupUpdatedResponse { |
||||
error: string; |
||||
errorType: string; |
||||
status: 'error' | 'success'; |
||||
} |
||||
|
||||
export type RulerGroupUpdatedResponse = GrafanaGroupUpdatedResponse | CloudGroupUpdatedResponse; |
||||
|
||||
export function isGrafanaGroupUpdatedResponse( |
||||
response: RulerGroupUpdatedResponse |
||||
): response is GrafanaGroupUpdatedResponse { |
||||
return 'message' in response; |
||||
} |
||||
|
||||
export function isCloudGroupUpdatedResponse( |
||||
response: RulerGroupUpdatedResponse |
||||
): response is CloudGroupUpdatedResponse { |
||||
return 'status' in response; |
||||
} |
@ -1,193 +0,0 @@ |
||||
import { HttpResponse } from 'msw'; |
||||
import { render } from 'test/test-utils'; |
||||
import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector'; |
||||
|
||||
import { AccessControlAction } from 'app/types'; |
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting'; |
||||
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; |
||||
import server, { setupMswServer } from '../../mockApi'; |
||||
import { mimirDataSource } from '../../mocks/server/configure'; |
||||
import { alertingFactory } from '../../mocks/server/db'; |
||||
import { rulerRuleGroupHandler as grafanaRulerRuleGroupHandler } from '../../mocks/server/handlers/grafanaRuler'; |
||||
import { rulerRuleGroupHandler } from '../../mocks/server/handlers/mimirRuler'; |
||||
import { grantPermissionsHelper } from '../../test/test-utils'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||
|
||||
import { EditRuleGroupModal } from './EditRuleGroupModal'; |
||||
|
||||
const ui = { |
||||
input: { |
||||
namespace: byLabelText(/^Folder|^Namespace/, { exact: true }), |
||||
group: byLabelText(/Evaluation group/), |
||||
interval: byLabelText(/Evaluation interval/), |
||||
}, |
||||
folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role
|
||||
table: byTestId('dynamic-table'), |
||||
tableRows: byTestId('row'), |
||||
noRulesText: byText('This group does not contain alert rules.'), |
||||
}; |
||||
|
||||
const noop = () => jest.fn(); |
||||
setupMswServer(); |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
...jest.requireActual('@grafana/runtime'), |
||||
useReturnToPrevious: jest.fn(), |
||||
})); |
||||
|
||||
describe('EditGroupModal component on cloud alert rules', () => { |
||||
it('Should disable all inputs but interval when intervalEditOnly is set', async () => { |
||||
const { rulerConfig } = mimirDataSource(); |
||||
|
||||
const group = alertingFactory.ruler.group.build({ |
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()], |
||||
}); |
||||
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use( |
||||
rulerRuleGroupHandler({ |
||||
response: HttpResponse.json(group), |
||||
}) |
||||
); |
||||
|
||||
const rulerGroupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName: rulerConfig.dataSourceName, |
||||
groupName: 'default-group', |
||||
namespaceName: 'my-namespace', |
||||
}; |
||||
|
||||
render( |
||||
<EditRuleGroupModal |
||||
ruleGroupIdentifier={rulerGroupIdentifier} |
||||
intervalEditOnly |
||||
onClose={noop} |
||||
rulerConfig={rulerConfig} |
||||
/> |
||||
); |
||||
|
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); |
||||
expect(ui.input.group.get()).toHaveAttribute('readonly'); |
||||
expect(ui.input.interval.get()).not.toHaveAttribute('readonly'); |
||||
}); |
||||
|
||||
it('Should show alert table in case of having some non-recording rules in the group', async () => { |
||||
const { dataSource, rulerConfig } = mimirDataSource(); |
||||
|
||||
const group = alertingFactory.ruler.group.build({ |
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()], |
||||
}); |
||||
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use( |
||||
rulerRuleGroupHandler({ |
||||
response: HttpResponse.json(group), |
||||
}) |
||||
); |
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName: dataSource.name, |
||||
groupName: group.name, |
||||
namespaceName: 'ns1', |
||||
}; |
||||
|
||||
render(<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={rulerConfig} onClose={noop} />); |
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue('ns1'); |
||||
expect(ui.input.namespace.get()).not.toHaveAttribute('readonly'); |
||||
expect(ui.input.group.get()).toHaveValue(group.name); |
||||
|
||||
// @ts-ignore
|
||||
const ruleName = group.rules.at(0).alert; |
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(1); // Only one rule is non-recording
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(ruleName); |
||||
}); |
||||
|
||||
it('Should not show alert table in case of having exclusively recording rules in the group', async () => { |
||||
const { dataSource, rulerConfig } = mimirDataSource(); |
||||
|
||||
const group = alertingFactory.ruler.group.build({ |
||||
rules: [alertingFactory.ruler.recordingRule.build(), alertingFactory.ruler.recordingRule.build()], |
||||
}); |
||||
|
||||
// @TODO need to simplify this a bit I think
|
||||
server.use( |
||||
rulerRuleGroupHandler({ |
||||
response: HttpResponse.json(group), |
||||
}) |
||||
); |
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName: dataSource.name, |
||||
groupName: group.name, |
||||
namespaceName: 'ns1', |
||||
}; |
||||
|
||||
render(<EditRuleGroupModal rulerConfig={rulerConfig} ruleGroupIdentifier={ruleGroupIdentifier} onClose={noop} />); |
||||
expect(ui.table.query()).not.toBeInTheDocument(); |
||||
expect(await ui.noRulesText.find()).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('EditGroupModal component on grafana-managed alert rules', () => { |
||||
// @TODO simplify folder stuff, should also have a higher-level function to set these up
|
||||
const folder = alertingFactory.folder.build(); |
||||
const NAMESPACE_UID = folder.uid; |
||||
|
||||
const group = alertingFactory.ruler.group.build({ |
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.alertingRule.build()], |
||||
}); |
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME, |
||||
groupName: group.name, |
||||
namespaceName: NAMESPACE_UID, |
||||
}; |
||||
|
||||
beforeEach(() => { |
||||
grantPermissionsHelper([ |
||||
AccessControlAction.AlertingRuleCreate, |
||||
AccessControlAction.AlertingRuleRead, |
||||
AccessControlAction.AlertingRuleUpdate, |
||||
]); |
||||
|
||||
server.use( |
||||
grafanaRulerRuleGroupHandler({ |
||||
response: HttpResponse.json(group), |
||||
}) |
||||
); |
||||
}); |
||||
|
||||
const renderWithGrafanaGroup = () => |
||||
render( |
||||
<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={GRAFANA_RULER_CONFIG} onClose={noop} /> |
||||
); |
||||
|
||||
it('Should show alert table', async () => { |
||||
renderWithGrafanaGroup(); |
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID); |
||||
expect(ui.input.group.get()).toHaveValue(group.name); |
||||
expect(ui.input.interval.get()).toHaveValue(group.interval); |
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(2); |
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(group.rules.at(0).alert); |
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(1)).toHaveTextContent(group.rules.at(1).alert); |
||||
}); |
||||
|
||||
it('Should have folder input in readonly mode', async () => { |
||||
renderWithGrafanaGroup(); |
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); |
||||
}); |
||||
|
||||
it('Should not display folder link if no folderUrl provided', async () => { |
||||
renderWithGrafanaGroup(); |
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID); |
||||
expect(ui.folderLink.query()).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,451 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { compact } from 'lodash'; |
||||
import { useMemo } from 'react'; |
||||
import { FieldValues, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { |
||||
Alert, |
||||
Badge, |
||||
Button, |
||||
Field, |
||||
Input, |
||||
Label, |
||||
LinkButton, |
||||
LoadingPlaceholder, |
||||
Modal, |
||||
Stack, |
||||
useStyles2, |
||||
} from '@grafana/ui'; |
||||
import { useAppNotification } from 'app/core/copy/appNotification'; |
||||
import { Trans, t } from 'app/core/internationalization'; |
||||
import { dispatch } from 'app/store/store'; |
||||
import { RuleGroupIdentifier, RulerDataSourceConfig } from 'app/types/unified-alerting'; |
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi'; |
||||
import { |
||||
useMoveRuleGroup, |
||||
useRenameRuleGroup, |
||||
useUpdateRuleGroupConfiguration, |
||||
} from '../../hooks/ruleGroup/useUpdateRuleGroup'; |
||||
import { anyOfRequestState } from '../../hooks/useAsync'; |
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults'; |
||||
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions'; |
||||
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||
import { stringifyErrorLike } from '../../utils/misc'; |
||||
import { AlertInfo, getAlertInfo, rulerRuleType } from '../../utils/rules'; |
||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time'; |
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; |
||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; |
||||
import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick'; |
||||
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; |
||||
|
||||
const useRuleGroupDefinition = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery; |
||||
|
||||
const ITEMS_PER_PAGE = 10; |
||||
|
||||
function ForBadge({ message, error }: { message: string; error?: boolean }) { |
||||
if (error) { |
||||
return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />; |
||||
} else { |
||||
return <Badge color="orange" icon="exclamation-triangle" text={'Unknown'} tooltip={message} />; |
||||
} |
||||
} |
||||
|
||||
const isValidEvaluation = (evaluation: string) => { |
||||
try { |
||||
const duration = parsePrometheusDuration(evaluation); |
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) { |
||||
return false; |
||||
} |
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} catch (error) { |
||||
return false; |
||||
} |
||||
}; |
||||
|
||||
type AlertsWithForTableColumnProps = DynamicTableColumnProps<AlertInfo>; |
||||
type AlertsWithForTableProps = DynamicTableItemProps<AlertInfo>; |
||||
|
||||
export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const { watch } = useFormContext<FormValues>(); |
||||
const currentInterval = watch('groupInterval'); |
||||
const unknownCurrentInterval = !Boolean(currentInterval); |
||||
|
||||
const rows: AlertsWithForTableProps[] = rulesWithoutRecordingRules |
||||
.slice() |
||||
.map((rule: RulerRuleDTO, index) => ({ |
||||
id: index, |
||||
data: getAlertInfo(rule, currentInterval), |
||||
})) |
||||
.sort( |
||||
(alert1, alert2) => |
||||
safeParsePrometheusDuration(alert1.data.forDuration ?? '') - |
||||
safeParsePrometheusDuration(alert2.data.forDuration ?? '') |
||||
); |
||||
|
||||
const columns: AlertsWithForTableColumnProps[] = useMemo(() => { |
||||
return [ |
||||
{ |
||||
id: 'alertName', |
||||
label: 'Alert', |
||||
renderCell: ({ data: { alertName } }) => { |
||||
return <>{alertName}</>; |
||||
}, |
||||
size: '330px', |
||||
}, |
||||
{ |
||||
id: 'for', |
||||
label: 'Pending period', |
||||
renderCell: ({ data: { forDuration } }) => { |
||||
return <>{forDuration}</>; |
||||
}, |
||||
size: 0.5, |
||||
}, |
||||
{ |
||||
id: 'numberEvaluations', |
||||
label: '#Eval', |
||||
renderCell: ({ data: { evaluationsToFire: numberEvaluations } }) => { |
||||
if (unknownCurrentInterval) { |
||||
return <ForBadge message="#Evaluations not available." />; |
||||
} else { |
||||
if (!isValidEvaluation(currentInterval)) { |
||||
return <ForBadge message={'Invalid evaluation interval format'} error />; |
||||
} |
||||
if (numberEvaluations === 0) { |
||||
return ( |
||||
<ForBadge message="Invalid 'For' value: it should be greater or equal to evaluation interval." error /> |
||||
); |
||||
} else { |
||||
return <>{numberEvaluations}</>; |
||||
} |
||||
} |
||||
}, |
||||
size: 0.4, |
||||
}, |
||||
]; |
||||
}, [currentInterval, unknownCurrentInterval]); |
||||
|
||||
return ( |
||||
<div className={styles.tableWrapper}> |
||||
<DynamicTable items={rows} cols={columns} pagination={{ itemsPerPage: ITEMS_PER_PAGE }} /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
interface FormValues { |
||||
namespaceName: string; |
||||
groupName: string; |
||||
groupInterval: string; |
||||
} |
||||
|
||||
export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: RulerRuleDTO[]): RegisterOptions<T> => ({ |
||||
required: { |
||||
value: true, |
||||
message: 'Required.', |
||||
}, |
||||
validate: (evaluateEvery: string) => { |
||||
try { |
||||
const duration = parsePrometheusDuration(evaluateEvery); |
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) { |
||||
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; |
||||
} |
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { |
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; |
||||
} |
||||
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) { |
||||
return true; |
||||
} else { |
||||
const rulePendingPeriods = rules.map((rule) => { |
||||
const { forDuration } = getAlertInfo(rule, evaluateEvery); |
||||
return forDuration ? safeParsePrometheusDuration(forDuration) : null; |
||||
}); |
||||
// 0 is a special case which disables the pending period at all
|
||||
const smallestPendingPeriod = Math.min( |
||||
...rulePendingPeriods.filter((period): period is number => period !== null && period !== 0) |
||||
); |
||||
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(smallestPendingPeriod)}".`; |
||||
} |
||||
} catch (error) { |
||||
return error instanceof Error ? error.message : 'Failed to parse duration'; |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
export interface ModalProps { |
||||
ruleGroupIdentifier: RuleGroupIdentifier; |
||||
folderTitle?: string; |
||||
rulerConfig: RulerDataSourceConfig; |
||||
onClose: (saved?: boolean) => void; |
||||
intervalEditOnly?: boolean; |
||||
folderUrl?: string; |
||||
hideFolder?: boolean; |
||||
} |
||||
|
||||
export interface ModalFormProps { |
||||
ruleGroupIdentifier: RuleGroupIdentifier; |
||||
folderTitle?: string; // used to display the GMA folder title
|
||||
ruleGroup: RulerRuleGroupDTO; |
||||
onClose: (saved?: boolean) => void; |
||||
intervalEditOnly?: boolean; |
||||
folderUrl?: string; |
||||
hideFolder?: boolean; |
||||
} |
||||
|
||||
// this component just wraps the modal with some loading state for grabbing rules and such
|
||||
export function EditRuleGroupModal(props: ModalProps) { |
||||
const { ruleGroupIdentifier, rulerConfig, intervalEditOnly, onClose } = props; |
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName; |
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; |
||||
|
||||
const modalTitle = |
||||
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group'; |
||||
|
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const { |
||||
data: ruleGroup, |
||||
error, |
||||
isLoading, |
||||
} = useRuleGroupDefinition({ |
||||
group: ruleGroupIdentifier.groupName, |
||||
namespace: ruleGroupIdentifier.namespaceName, |
||||
rulerConfig, |
||||
}); |
||||
|
||||
const loadingText = t('alerting.common.loading', 'Loading...'); |
||||
|
||||
return ( |
||||
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}> |
||||
{isLoading && <LoadingPlaceholder text={loadingText} />} |
||||
{error ? stringifyErrorLike(error) : null} |
||||
{ruleGroup && <EditRuleGroupModalForm {...props} ruleGroup={ruleGroup} />} |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
export function EditRuleGroupModalForm(props: ModalFormProps): React.ReactElement { |
||||
const { ruleGroup, ruleGroupIdentifier, folderTitle, onClose, intervalEditOnly } = props; |
||||
|
||||
const styles = useStyles2(getStyles); |
||||
const notifyApp = useAppNotification(); |
||||
|
||||
/** |
||||
* This modal can take 3 different actions, depending on what fields were updated. |
||||
* |
||||
* 1. update the rule group details without renaming either the namespace or group |
||||
* 2. rename the rule group, but keeping it in the same namespace |
||||
* 3. move the rule group to a new namespace, optionally with a different group name |
||||
*/ |
||||
const [updateRuleGroup, updateRuleGroupState] = useUpdateRuleGroupConfiguration(); |
||||
const [renameRuleGroup, renameRuleGroupState] = useRenameRuleGroup(); |
||||
const [moveRuleGroup, moveRuleGroupState] = useMoveRuleGroup(); |
||||
|
||||
const { loading, error } = anyOfRequestState(updateRuleGroupState, moveRuleGroupState, renameRuleGroupState); |
||||
|
||||
const defaultValues = useMemo( |
||||
(): FormValues => ({ |
||||
namespaceName: ruleGroupIdentifier.namespaceName, |
||||
groupName: ruleGroupIdentifier.groupName, |
||||
groupInterval: ruleGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, |
||||
}), |
||||
[ruleGroup?.interval, ruleGroupIdentifier.groupName, ruleGroupIdentifier.namespaceName] |
||||
); |
||||
|
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName; |
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; |
||||
|
||||
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; |
||||
|
||||
const onSubmit = async (values: FormValues) => { |
||||
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
|
||||
const updatedNamespaceName = values.namespaceName; |
||||
const updatedGroupName = values.groupName; |
||||
const updatedInterval = values.groupInterval; |
||||
|
||||
// GMA alert rules cannot be moved to another folder, we currently do not support it but it should be doable (with caveats).
|
||||
const shouldMove = isGrafanaManagedGroup ? false : updatedNamespaceName !== ruleGroupIdentifier.namespaceName; |
||||
const shouldRename = updatedGroupName !== ruleGroupIdentifier.groupName; |
||||
|
||||
try { |
||||
if (shouldMove) { |
||||
await moveRuleGroup.execute(ruleGroupIdentifier, updatedNamespaceName, updatedGroupName, updatedInterval); |
||||
} else if (shouldRename) { |
||||
await renameRuleGroup.execute(ruleGroupIdentifier, updatedGroupName, updatedInterval); |
||||
} else { |
||||
await updateRuleGroup.execute(ruleGroupIdentifier, updatedInterval); |
||||
} |
||||
onClose(true); |
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName })); |
||||
} catch (_error) {} // React hook form will handle errors
|
||||
}; |
||||
|
||||
const formAPI = useForm<FormValues>({ |
||||
mode: 'onBlur', |
||||
defaultValues, |
||||
shouldFocusError: true, |
||||
}); |
||||
|
||||
const { |
||||
handleSubmit, |
||||
register, |
||||
watch, |
||||
formState: { isDirty, errors, isValid }, |
||||
setValue, |
||||
getValues, |
||||
} = formAPI; |
||||
|
||||
const onInvalid = () => { |
||||
notifyApp.error('There are errors in the form. Correct the errors and retry.'); |
||||
}; |
||||
|
||||
const rulesWithoutRecordingRules = compact(ruleGroup?.rules.filter((rule) => !rulerRuleType.any.recordingRule(rule))); |
||||
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0; |
||||
|
||||
return ( |
||||
<FormProvider {...formAPI}> |
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}> |
||||
{!props.hideFolder && ( |
||||
<Stack gap={1} alignItems={'center'}> |
||||
<Field |
||||
className={styles.formInput} |
||||
label={ |
||||
<Label |
||||
htmlFor="namespaceName" |
||||
description={ |
||||
!isGrafanaManagedGroup && |
||||
'Change the current namespace name. Moving groups between namespaces is not supported' |
||||
} |
||||
> |
||||
{nameSpaceLabel} |
||||
</Label> |
||||
} |
||||
invalid={Boolean(errors.namespaceName) ? true : undefined} |
||||
error={errors.namespaceName?.message} |
||||
> |
||||
<Input |
||||
id="namespaceName" |
||||
readOnly={intervalEditOnly || isGrafanaManagedGroup} |
||||
value={folderTitle} |
||||
{...register('namespaceName', { |
||||
required: 'Namespace name is required.', |
||||
})} |
||||
/> |
||||
</Field> |
||||
{isGrafanaManagedGroup && props.folderUrl && ( |
||||
<LinkButton |
||||
href={props.folderUrl} |
||||
title="Go to folder" |
||||
variant="secondary" |
||||
icon="folder-open" |
||||
target="_blank" |
||||
/> |
||||
)} |
||||
</Stack> |
||||
)} |
||||
<Field |
||||
label={ |
||||
<Label htmlFor="groupName" description="A group evaluates all its rules over the same evaluation interval."> |
||||
Evaluation group |
||||
</Label> |
||||
} |
||||
invalid={!!errors.groupName} |
||||
error={errors.groupName?.message} |
||||
> |
||||
<Input |
||||
autoFocus={true} |
||||
id="groupName" |
||||
readOnly={intervalEditOnly} |
||||
{...register('groupName', { |
||||
required: 'Evaluation group name is required.', |
||||
})} |
||||
/> |
||||
</Field> |
||||
<Field |
||||
label={ |
||||
<Label |
||||
htmlFor="groupInterval" |
||||
description="How often is the rule evaluated. Applies to every rule within the group." |
||||
> |
||||
<Stack gap={0.5}>Evaluation interval</Stack> |
||||
</Label> |
||||
} |
||||
invalid={Boolean(errors.groupInterval) ? true : undefined} |
||||
error={errors.groupInterval?.message} |
||||
> |
||||
<Stack direction="column"> |
||||
<Input |
||||
id="groupInterval" |
||||
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL} |
||||
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))} |
||||
/> |
||||
<EvaluationGroupQuickPick |
||||
currentInterval={getValues('groupInterval')} |
||||
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })} |
||||
/> |
||||
</Stack> |
||||
</Field> |
||||
|
||||
{/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */} |
||||
{isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( |
||||
<EvaluationIntervalLimitExceeded /> |
||||
)} |
||||
|
||||
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>} |
||||
{hasSomeNoRecordingRules && ( |
||||
<> |
||||
<div>List of rules that belong to this group</div> |
||||
<div className={styles.evalRequiredLabel}> |
||||
#Eval column represents the number of evaluations needed before alert starts firing. |
||||
</div> |
||||
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} /> |
||||
</> |
||||
)} |
||||
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>} |
||||
<div className={styles.modalButtons}> |
||||
<Modal.ButtonRow> |
||||
<Button variant="secondary" type="button" disabled={loading} onClick={() => onClose(false)} fill="outline"> |
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans> |
||||
</Button> |
||||
<Button type="submit" disabled={!isDirty || !isValid || loading}> |
||||
{loading ? 'Saving...' : 'Save'} |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</div> |
||||
</form> |
||||
</FormProvider> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
modal: css({ |
||||
maxWidth: '560px', |
||||
}), |
||||
modalButtons: css({ |
||||
top: '-24px', |
||||
position: 'relative', |
||||
}), |
||||
formInput: css({ |
||||
flex: 1, |
||||
}), |
||||
tableWrapper: css({ |
||||
marginTop: theme.spacing(2), |
||||
marginBottom: theme.spacing(2), |
||||
height: '100%', |
||||
}), |
||||
evalRequiredLabel: css({ |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
}); |
@ -1,280 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { |
||||
DragDropContext, |
||||
Draggable, |
||||
DraggableProvided, |
||||
DropResult, |
||||
Droppable, |
||||
DroppableProvided, |
||||
} from '@hello-pangea/dnd'; |
||||
import cx from 'classnames'; |
||||
import { produce } from 'immer'; |
||||
import { useCallback, useEffect, useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import { dispatch } from 'app/store/store'; |
||||
import { |
||||
CombinedRuleGroup, |
||||
CombinedRuleNamespace, |
||||
RuleGroupIdentifier, |
||||
RulerDataSourceConfig, |
||||
} from 'app/types/unified-alerting'; |
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi'; |
||||
import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup'; |
||||
import { isLoading } from '../../hooks/useAsync'; |
||||
import { SwapOperation, swapItems } from '../../reducers/ruler/ruleGroups'; |
||||
import { fetchRulerRulesAction } from '../../state/actions'; |
||||
import { isCloudRulesSource } from '../../utils/datasource'; |
||||
import { hashRulerRule } from '../../utils/rule-id'; |
||||
import { getRuleName, rulerRuleType, rulesSourceToDataSourceName } from '../../utils/rules'; |
||||
|
||||
interface ModalProps { |
||||
namespace: CombinedRuleNamespace; |
||||
group: CombinedRuleGroup; |
||||
onClose: () => void; |
||||
folderUid?: string; |
||||
rulerConfig: RulerDataSourceConfig; |
||||
} |
||||
|
||||
type RulerRuleWithUID = { uid: string } & RulerRuleDTO; |
||||
|
||||
export const ReorderCloudGroupModal = (props: ModalProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
const { group, namespace, onClose, folderUid } = props; |
||||
const [operations, setOperations] = useState<Array<[number, number]>>([]); |
||||
|
||||
const [reorderRulesInGroup, reorderState] = useReorderRuleForRuleGroup(); |
||||
const isUpdating = isLoading(reorderState); |
||||
|
||||
// The list of rules might have been filtered before we get to this reordering modal
|
||||
// We need to grab the full (unfiltered) list
|
||||
const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery( |
||||
{ |
||||
rulerConfig: props.rulerConfig, |
||||
namespace: folderUid ?? namespace.name, |
||||
group: group.name, |
||||
}, |
||||
{ refetchOnMountOrArgChange: true } |
||||
); |
||||
|
||||
const [rulesList, setRulesList] = useState<RulerRuleDTO[]>([]); |
||||
|
||||
useEffect(() => { |
||||
if (ruleGroup) { |
||||
setRulesList(ruleGroup?.rules); |
||||
} |
||||
}, [ruleGroup]); |
||||
|
||||
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 swapOperation: SwapOperation = [result.source.index, result.destination.index]; |
||||
|
||||
// add old index and new index to the modifications object
|
||||
setOperations( |
||||
produce(operations, (draft) => { |
||||
draft.push(swapOperation); |
||||
}) |
||||
); |
||||
|
||||
// re-order the rules list for the UI rendering
|
||||
const newOrderedRules = produce(rulesList, (draft) => { |
||||
swapItems(draft, swapOperation); |
||||
}); |
||||
setRulesList(newOrderedRules); |
||||
}, |
||||
[rulesList, operations] |
||||
); |
||||
|
||||
const updateRulesOrder = useCallback(async () => { |
||||
const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); |
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName, |
||||
groupName: group.name, |
||||
namespaceName: folderUid ?? namespace.name, |
||||
}; |
||||
|
||||
await reorderRulesInGroup.execute(ruleGroupIdentifier, operations); |
||||
// TODO: Remove once RTKQ is more prevalently used
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName })); |
||||
onClose(); |
||||
}, [namespace.rulesSource, namespace.name, group.name, folderUid, reorderRulesInGroup, operations, onClose]); |
||||
|
||||
// assign unique but stable identifiers to each (alerting / recording) rule
|
||||
const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({ |
||||
...rulerRule, |
||||
uid: hashRulerRule(rulerRule), |
||||
})); |
||||
|
||||
return ( |
||||
<Modal |
||||
className={styles.modal} |
||||
isOpen={true} |
||||
title={<ModalHeader namespace={namespace} group={group} />} |
||||
onDismiss={onClose} |
||||
onClickBackdrop={onClose} |
||||
> |
||||
{loadingRules && 'Loading...'} |
||||
{rulesWithUID.length > 0 && ( |
||||
<> |
||||
<DragDropContext onDragEnd={onDragEnd}> |
||||
<Droppable |
||||
droppableId="alert-list" |
||||
mode="standard" |
||||
renderClone={(provided, _snapshot, rubric) => ( |
||||
<ListItem provided={provided} rule={rulesWithUID[rubric.source.index]} isClone /> |
||||
)} |
||||
> |
||||
{(droppableProvided: DroppableProvided) => ( |
||||
<div |
||||
ref={droppableProvided.innerRef} |
||||
className={cx(styles.listContainer, isUpdating && styles.disabled)} |
||||
{...droppableProvided.droppableProps} |
||||
> |
||||
{rulesWithUID.map((rule, index) => ( |
||||
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={isUpdating}> |
||||
{(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />} |
||||
</Draggable> |
||||
))} |
||||
{droppableProvided.placeholder} |
||||
</div> |
||||
)} |
||||
</Droppable> |
||||
</DragDropContext> |
||||
<Modal.ButtonRow> |
||||
<Button variant="secondary" fill="outline" onClick={onClose}> |
||||
<Trans i18nKey={'common.cancel'}>Cancel</Trans> |
||||
</Button> |
||||
<Button onClick={() => updateRulesOrder()} disabled={isUpdating}> |
||||
<Trans i18nKey={'common.save'}>Save</Trans> |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</> |
||||
)} |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
provided: DraggableProvided; |
||||
rule: RulerRuleDTO; |
||||
isClone?: boolean; |
||||
isDragging?: boolean; |
||||
} |
||||
|
||||
const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
// @TODO does this work with Grafana-managed recording rules too? Double check that.
|
||||
return ( |
||||
<div |
||||
data-testid="reorder-alert-rule" |
||||
className={cx(styles.listItem, isClone && 'isClone', isDragging && 'isDragging')} |
||||
ref={provided.innerRef} |
||||
{...provided.draggableProps} |
||||
{...provided.dragHandleProps} |
||||
> |
||||
<div className={styles.listItemName}> |
||||
{getRuleName(rule)} |
||||
{rulerRuleType.any.recordingRule(rule) && ( |
||||
<> |
||||
{' '} |
||||
<Badge text="Recording" color="purple" /> |
||||
</> |
||||
)} |
||||
</div> |
||||
{rulerRuleType.dataSource.alertingRule(rule) && <div className={styles.listItemName}>{rule.alert}</div>} |
||||
<Icon name="draggabledots" /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
interface ModalHeaderProps { |
||||
namespace: CombinedRuleNamespace; |
||||
group: CombinedRuleGroup; |
||||
} |
||||
|
||||
const ModalHeader = ({ namespace, group }: ModalHeaderProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.header}> |
||||
<Icon name="folder" /> |
||||
{isCloudRulesSource(namespace.rulesSource) && ( |
||||
<Tooltip content={namespace.rulesSource.name} placement="top"> |
||||
<img |
||||
alt={namespace.rulesSource.meta.name} |
||||
className={styles.dataSourceIcon} |
||||
src={namespace.rulesSource.meta.info.logos.small} |
||||
/> |
||||
</Tooltip> |
||||
)} |
||||
<span>{namespace.name}</span> |
||||
<Icon name="angle-right" /> |
||||
<span>{group.name}</span> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
modal: css({ |
||||
maxWidth: '640px', |
||||
maxHeight: '80%', |
||||
overflow: 'hidden', |
||||
}), |
||||
listItem: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
|
||||
gap: theme.spacing(), |
||||
|
||||
background: theme.colors.background.primary, |
||||
color: theme.colors.text.secondary, |
||||
|
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`, |
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`, |
||||
|
||||
'&:last-child': { |
||||
borderBottom: 'none', |
||||
}, |
||||
|
||||
'&.isClone': { |
||||
border: `solid 1px ${theme.colors.primary.shade}`, |
||||
}, |
||||
}), |
||||
listContainer: css({ |
||||
userSelect: 'none', |
||||
border: `solid 1px ${theme.colors.border.medium}`, |
||||
}), |
||||
disabled: css({ |
||||
opacity: '0.5', |
||||
pointerEvents: 'none', |
||||
}), |
||||
listItemName: css({ |
||||
flex: 1, |
||||
|
||||
overflow: 'hidden', |
||||
textOverflow: 'ellipsis', |
||||
whiteSpace: 'nowrap', |
||||
}), |
||||
header: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
|
||||
gap: theme.spacing(1), |
||||
}), |
||||
dataSourceIcon: css({ |
||||
width: theme.spacing(2), |
||||
height: theme.spacing(2), |
||||
}), |
||||
}); |
@ -0,0 +1,43 @@ |
||||
import { FieldValues, RegisterOptions } from 'react-hook-form'; |
||||
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { MIN_TIME_RANGE_STEP_S } from '../components/rule-editor/GrafanaEvaluationBehavior'; |
||||
import { rulesInSameGroupHaveInvalidFor } from '../state/actions'; |
||||
import { getAlertInfo } from '../utils/rules'; |
||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../utils/time'; |
||||
|
||||
export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: RulerRuleDTO[]): RegisterOptions<T> => ({ |
||||
required: { |
||||
value: true, |
||||
message: 'Required.', |
||||
}, |
||||
validate: (evaluateEvery: string) => { |
||||
try { |
||||
const duration = parsePrometheusDuration(evaluateEvery); |
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) { |
||||
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; |
||||
} |
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { |
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; |
||||
} |
||||
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) { |
||||
return true; |
||||
} else { |
||||
const rulePendingPeriods = rules.map((rule) => { |
||||
const { forDuration } = getAlertInfo(rule, evaluateEvery); |
||||
return forDuration ? safeParsePrometheusDuration(forDuration) : null; |
||||
}); |
||||
// 0 is a special case which disables the pending period at all
|
||||
const smallestPendingPeriod = Math.min( |
||||
...rulePendingPeriods.filter((period): period is number => period !== null && period !== 0) |
||||
); |
||||
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(smallestPendingPeriod)}".`; |
||||
} |
||||
} catch (error) { |
||||
return error instanceof Error ? error.message : 'Failed to parse duration'; |
||||
} |
||||
}, |
||||
}); |
@ -0,0 +1,156 @@ |
||||
import { render, within } from 'test/test-utils'; |
||||
import { byRole } from 'testing-library-selector'; |
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data'; |
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; |
||||
import { AccessControlAction } from 'app/types'; |
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting'; |
||||
import { PromRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { setupMswServer } from '../mockApi'; |
||||
import { grantUserPermissions } from '../mocks'; |
||||
import { setPrometheusRules } from '../mocks/server/configure'; |
||||
import { alertingFactory } from '../mocks/server/db'; |
||||
import { createViewLinkV2 } from '../utils/misc'; |
||||
import { fromRulerRuleAndGroupIdentifierV2 } from '../utils/rule-id'; |
||||
|
||||
import { DataSourceGroupLoader } from './DataSourceGroupLoader'; |
||||
import { createViewLinkFromIdentifier } from './DataSourceRuleListItem'; |
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false })); |
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false })); |
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleExternalWrite]); |
||||
|
||||
setupMswServer(); |
||||
|
||||
const ui = { |
||||
ruleItem: (ruleName: string | RegExp) => byRole('treeitem', { name: ruleName }), |
||||
editButton: byRole('link', { name: 'Edit' }), |
||||
moreButton: byRole('button', { name: 'More' }), |
||||
}; |
||||
|
||||
const vanillaPromDs = alertingFactory.dataSource.vanillaPrometheus().build(); |
||||
const mimirDs = alertingFactory.dataSource.mimir().build(); |
||||
|
||||
describe('DataSourceGroupLoader', () => { |
||||
const promRuleSource = getDataSourceIdentifier(vanillaPromDs); |
||||
const mimirRuleSource = getDataSourceIdentifier(mimirDs); |
||||
|
||||
describe('Vanilla Prometheus', () => { |
||||
const promGroup = alertingFactory.prometheus.group.build({ |
||||
file: 'test-namespace', |
||||
rules: [ |
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-1' }), |
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-2' }), |
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-3' }), |
||||
], |
||||
}); |
||||
const groupIdentifier = getPromGroupIdentifier(promRuleSource, promGroup); |
||||
|
||||
it('should render a list of rules for data sources without ruler', async () => { |
||||
setPrometheusRules(vanillaPromDs, [promGroup]); |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const ruleListItems = await ui.ruleItem(/prom-only-rule/).findAll(); |
||||
expect(ruleListItems).toHaveLength(3); |
||||
|
||||
promGroup.rules.forEach((rule, index) => { |
||||
const ruleLink = within(ruleListItems[index]).getByRole('link', { name: `prom-only-rule-${index + 1}` }); |
||||
expect(ruleLink).toHaveAttribute('href', createViewLinkV2(groupIdentifier, rule)); |
||||
}); |
||||
}); |
||||
|
||||
it('should not render rule action buttons', async () => { |
||||
setPrometheusRules(vanillaPromDs, [promGroup]); |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const ruleListItems = await ui.ruleItem(/prom-only-rule/).findAll(); |
||||
expect(ruleListItems).toHaveLength(3); |
||||
|
||||
ruleListItems.forEach((ruleListItem) => { |
||||
expect(ui.editButton.query(ruleListItem)).not.toBeInTheDocument(); |
||||
expect(ui.moreButton.query(ruleListItem)).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Ruler-enabled data sources', () => { |
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'mimir-rule-1' }); |
||||
const rulerOnlyRule = alertingFactory.ruler.alertingRule.build({ alert: 'mimir-only-rule' }); |
||||
alertingFactory.ruler.group.build( |
||||
{ name: 'mimir-group', rules: [rulerRule, rulerOnlyRule] }, |
||||
{ transient: { addToNamespace: 'mimir-namespace' } } |
||||
); |
||||
const promGroup = alertingFactory.prometheus.group.build({ |
||||
name: 'mimir-group', |
||||
file: 'mimir-namespace', |
||||
rules: [ |
||||
alertingFactory.prometheus.rule.fromRuler(rulerRule).build(), |
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule' }), |
||||
], |
||||
}); |
||||
const groupIdentifier = getPromGroupIdentifier(mimirRuleSource, promGroup); |
||||
|
||||
beforeEach(() => { |
||||
setPrometheusRules(mimirDs, [promGroup]); |
||||
}); |
||||
|
||||
it('should render a list of rules for data sources with ruler', async () => { |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const ruleListItems = await ui.ruleItem(/mimir-rule/).findAll(); |
||||
expect(ruleListItems).toHaveLength(1); |
||||
|
||||
const ruleLink = within(ruleListItems[0]).getByRole('link', { name: 'mimir-rule-1' }); |
||||
expect(ruleLink).toHaveAttribute('href', getRuleLink(groupIdentifier, rulerRule)); |
||||
}); |
||||
|
||||
it('should render Edit and More buttons for rules that are present in ruler and prometheus', async () => { |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const mimirRule1 = await ui.ruleItem(/mimir-rule/).find(); |
||||
|
||||
expect(await ui.editButton.find(mimirRule1)).toBeInTheDocument(); |
||||
expect(await ui.moreButton.find(mimirRule1)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render creating state if a rules is only present in ruler', async () => { |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const mimirOnlyItem = await ui.ruleItem(/mimir-only-rule/).find(); |
||||
expect(within(mimirOnlyItem).getByTitle('Creating')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render deleting state if a rule is only present in prometheus', async () => { |
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />); |
||||
|
||||
const promOnlyItem = await ui.ruleItem(/prom-only-rule/).find(); |
||||
expect(within(promOnlyItem).getByTitle('Deleting')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function getPromGroupIdentifier( |
||||
promRuleSource: DataSourceRulesSourceIdentifier, |
||||
group: PromRuleGroupDTO |
||||
): DataSourceRuleGroupIdentifier { |
||||
return { |
||||
rulesSource: promRuleSource, |
||||
groupName: group.name, |
||||
namespace: { name: group.file }, |
||||
groupOrigin: 'datasource', |
||||
}; |
||||
} |
||||
|
||||
function getDataSourceIdentifier(dataSource: DataSourceInstanceSettings): DataSourceRulesSourceIdentifier { |
||||
return { |
||||
uid: dataSource.uid, |
||||
name: dataSource.name, |
||||
ruleSourceType: 'datasource', |
||||
}; |
||||
} |
||||
|
||||
function getRuleLink(groupIdentifier: DataSourceRuleGroupIdentifier, rulerRule: RulerRuleDTO) { |
||||
return createViewLinkFromIdentifier(fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rulerRule)); |
||||
} |
@ -0,0 +1,223 @@ |
||||
import { skipToken } from '@reduxjs/toolkit/query'; |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { isFetchError } from '@grafana/runtime'; |
||||
import { Alert } from '@grafana/ui'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import { DataSourceRuleGroupIdentifier } from 'app/types/unified-alerting'; |
||||
import { |
||||
PromRuleDTO, |
||||
PromRuleGroupDTO, |
||||
RulerCloudRuleDTO, |
||||
RulerRuleGroupDTO, |
||||
RulesSourceApplication, |
||||
} from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi'; |
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; |
||||
import { prometheusApi } from '../api/prometheusApi'; |
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; |
||||
import { hashRule } from '../utils/rule-id'; |
||||
import { getRuleName, isCloudRulerGroup } from '../utils/rules'; |
||||
|
||||
import { DataSourceRuleListItem } from './DataSourceRuleListItem'; |
||||
import { RuleOperationListItem } from './components/AlertRuleListItem'; |
||||
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; |
||||
import { RuleActionsButtons } from './components/RuleActionsButtons.V2'; |
||||
import { RuleOperation } from './components/RuleListIcon'; |
||||
import { matchRulesGroup } from './ruleMatching'; |
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; |
||||
const { useGetGroupsQuery } = prometheusApi; |
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi; |
||||
|
||||
export interface DataSourceGroupLoaderProps { |
||||
groupIdentifier: DataSourceRuleGroupIdentifier; |
||||
/** |
||||
* Used to display the same number of skeletons as there are rules |
||||
* The number of rules is typically known from paginated Prometheus response |
||||
* Ruler response might contain different number of rules, but in most cases what we get from Prometheus is fine |
||||
*/ |
||||
expectedRulesCount?: number; |
||||
} |
||||
|
||||
/** |
||||
* Loads an evaluation group from Prometheus and Ruler endpoints. |
||||
* Displays a loading skeleton while the data is being fetched. |
||||
* Polls the Prometheus endpoint every 20 seconds to refresh the data. |
||||
*/ |
||||
export function DataSourceGroupLoader({ groupIdentifier, expectedRulesCount = 3 }: DataSourceGroupLoaderProps) { |
||||
const { namespace, groupName } = groupIdentifier; |
||||
const namespaceName = namespace.name; |
||||
|
||||
const { |
||||
data: promResponse, |
||||
isLoading: isPromResponseLoading, |
||||
isError: isPromResponseError, |
||||
} = useGetGroupsQuery( |
||||
{ |
||||
ruleSource: { uid: groupIdentifier.rulesSource.uid }, |
||||
namespace: namespaceName, |
||||
groupName: groupName, |
||||
}, |
||||
{ pollingInterval: RULE_LIST_POLL_INTERVAL_MS } |
||||
); |
||||
|
||||
const { |
||||
data: dsFeatures, |
||||
isLoading: isDsFeaturesLoading, |
||||
isError: isDsFeaturesError, |
||||
} = useDiscoverDsFeaturesQuery({ |
||||
uid: groupIdentifier.rulesSource.uid, |
||||
}); |
||||
|
||||
const { |
||||
data: rulerGroup, |
||||
error: rulerGroupError, |
||||
isFetching: isRulerGroupFetching, |
||||
isError: isRulerGroupError, |
||||
} = useGetRuleGroupForNamespaceQuery( |
||||
dsFeatures?.rulerConfig |
||||
? { |
||||
rulerConfig: dsFeatures?.rulerConfig, |
||||
namespace: namespaceName, |
||||
group: groupName, |
||||
} |
||||
: skipToken |
||||
); |
||||
|
||||
const isLoading = isPromResponseLoading || isDsFeaturesLoading || isRulerGroupFetching; |
||||
if (isLoading) { |
||||
return ( |
||||
<> |
||||
{Array.from({ length: expectedRulesCount }).map((_, index) => ( |
||||
<AlertRuleListItemSkeleton key={index} /> |
||||
))} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const isError = isPromResponseError || isDsFeaturesError || isRulerGroupError; |
||||
if (isError) { |
||||
if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) { |
||||
return ( |
||||
<Alert severity="warning" title={t('alerting.ds-group-loader.group-deleting', 'The group is being deleted')} /> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Alert |
||||
title={t( |
||||
'alerting.ds-group-loader.group-load-failed', |
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}', |
||||
{ groupName, namespaceName } |
||||
)} |
||||
severity="error" |
||||
/> |
||||
); |
||||
} |
||||
|
||||
// There should be always only one group in the response but some Prometheus-compatible data sources
|
||||
// implement different filter parameters
|
||||
const promGroup = promResponse?.data.groups.find((g) => g.file === namespaceName && g.name === groupName); |
||||
if (dsFeatures?.rulerConfig && rulerGroup && isCloudRulerGroup(rulerGroup) && promGroup) { |
||||
return ( |
||||
<RulerBasedGroupRules |
||||
groupIdentifier={groupIdentifier} |
||||
promGroup={promGroup} |
||||
rulerGroup={rulerGroup} |
||||
application={dsFeatures.application} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
// Data source without ruler
|
||||
if (promGroup) { |
||||
return ( |
||||
<> |
||||
{promGroup.rules.map((rule) => ( |
||||
<DataSourceRuleListItem |
||||
key={hashRule(rule)} |
||||
rule={rule} |
||||
groupIdentifier={groupIdentifier} |
||||
application={dsFeatures?.application} |
||||
/> |
||||
))} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
// This should never happen
|
||||
return ( |
||||
<Alert |
||||
title={t( |
||||
'alerting.ds-group-loader.group-load-failed', |
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}', |
||||
{ groupName, namespaceName } |
||||
)} |
||||
severity="warning" |
||||
/> |
||||
); |
||||
} |
||||
|
||||
interface RulerBasedGroupRulesProps { |
||||
groupIdentifier: DataSourceRuleGroupIdentifier; |
||||
promGroup: PromRuleGroupDTO<PromRuleDTO>; |
||||
rulerGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>; |
||||
application: RulesSourceApplication; |
||||
} |
||||
|
||||
export function RulerBasedGroupRules({ |
||||
groupIdentifier, |
||||
application, |
||||
promGroup, |
||||
rulerGroup, |
||||
}: RulerBasedGroupRulesProps) { |
||||
const { namespace, groupName } = groupIdentifier; |
||||
|
||||
const { matches, promOnlyRules } = useMemo(() => { |
||||
return matchRulesGroup(rulerGroup, promGroup); |
||||
}, [promGroup, rulerGroup]); |
||||
|
||||
return ( |
||||
<> |
||||
{rulerGroup.rules.map((rulerRule) => { |
||||
const promRule = matches.get(rulerRule); |
||||
|
||||
return promRule ? ( |
||||
<DataSourceRuleListItem |
||||
key={hashRule(promRule)} |
||||
rule={promRule} |
||||
rulerRule={rulerRule} |
||||
groupIdentifier={groupIdentifier} |
||||
application={application} |
||||
actions={ |
||||
<RuleActionsButtons rule={rulerRule} promRule={promRule} groupIdentifier={groupIdentifier} compact /> |
||||
} |
||||
/> |
||||
) : ( |
||||
<RuleOperationListItem |
||||
key={getRuleName(rulerRule)} |
||||
name={getRuleName(rulerRule)} |
||||
namespace={namespace.name} |
||||
group={groupName} |
||||
rulesSource={groupIdentifier.rulesSource} |
||||
application={application} |
||||
operation={RuleOperation.Creating} |
||||
/> |
||||
); |
||||
})} |
||||
{promOnlyRules.map((rule) => ( |
||||
<RuleOperationListItem |
||||
key={rule.name} |
||||
name={rule.name} |
||||
namespace={namespace.name} |
||||
group={groupName} |
||||
rulesSource={groupIdentifier.rulesSource} |
||||
application={application} |
||||
operation={RuleOperation.Deleting} |
||||
/> |
||||
))} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,79 @@ |
||||
import React from 'react'; |
||||
|
||||
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting'; |
||||
import { PromRuleType, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { Annotation } from '../utils/constants'; |
||||
import { fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id'; |
||||
import { getRuleName, getRulePluginOrigin, rulerRuleType } from '../utils/rules'; |
||||
import { createRelativeUrl } from '../utils/url'; |
||||
|
||||
import { |
||||
AlertRuleListItem, |
||||
RecordingRuleListItem, |
||||
RuleListItemCommonProps, |
||||
UnknownRuleListItem, |
||||
} from './components/AlertRuleListItem'; |
||||
|
||||
export interface DataSourceRuleListItemProps { |
||||
rule: Rule; |
||||
rulerRule?: RulerRuleDTO; |
||||
groupIdentifier: DataSourceRuleGroupIdentifier; |
||||
application?: RulesSourceApplication; |
||||
actions?: React.ReactNode; |
||||
} |
||||
|
||||
export function DataSourceRuleListItem({ |
||||
rule, |
||||
rulerRule, |
||||
groupIdentifier, |
||||
application, |
||||
actions, |
||||
}: DataSourceRuleListItemProps) { |
||||
const { rulesSource, namespace, groupName } = groupIdentifier; |
||||
|
||||
const ruleIdentifier = rulerRule |
||||
? fromRulerRule(rulesSource.name, namespace.name, groupName, rulerRule) |
||||
: fromRule(rulesSource.name, namespace.name, groupName, rule); |
||||
const href = createViewLinkFromIdentifier(ruleIdentifier); |
||||
const originMeta = getRulePluginOrigin(rule); |
||||
|
||||
// If ruler rule is available, we should use it as it contains fresh data
|
||||
const ruleName = rulerRule ? getRuleName(rulerRule) : rule.name; |
||||
const labels = rulerRule ? rulerRule.labels : rule.labels; |
||||
|
||||
const commonProps: RuleListItemCommonProps = { |
||||
name: ruleName, |
||||
rulesSource: rulesSource, |
||||
application: application, |
||||
group: groupName, |
||||
namespace: namespace.name, |
||||
href, |
||||
health: rule.health, |
||||
error: rule.lastError, |
||||
labels, |
||||
actions, |
||||
origin: originMeta, |
||||
}; |
||||
|
||||
switch (rule.type) { |
||||
case PromRuleType.Alerting: |
||||
const annotations = (rulerRuleType.any.alertingRule(rulerRule) ? rulerRule.annotations : rule.annotations) ?? {}; |
||||
const summary = annotations[Annotation.summary]; |
||||
|
||||
return ( |
||||
<AlertRuleListItem {...commonProps} summary={summary} state={rule.state} instancesCount={rule.alerts?.length} /> |
||||
); |
||||
case PromRuleType.Recording: |
||||
return <RecordingRuleListItem {...commonProps} />; |
||||
default: |
||||
return <UnknownRuleListItem ruleName={ruleName} groupIdentifier={groupIdentifier} ruleDefinition={rule} />; |
||||
} |
||||
} |
||||
|
||||
export function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { |
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier)); |
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName); |
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); |
||||
} |
@ -0,0 +1,181 @@ |
||||
import { render } from 'test/test-utils'; |
||||
import { byRole, byTitle } from 'testing-library-selector'; |
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; |
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; |
||||
import { |
||||
GrafanaPromRuleDTO, |
||||
GrafanaPromRuleGroupDTO, |
||||
PromAlertingRuleState, |
||||
PromRuleType, |
||||
RulerGrafanaRuleDTO, |
||||
RulerRuleGroupDTO, |
||||
} from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { setupMswServer } from '../mockApi'; |
||||
import { mockGrafanaPromAlertingRule, mockGrafanaRulerRule } from '../mocks'; |
||||
import { grafanaRulerGroup, grafanaRulerNamespace } from '../mocks/grafanaRulerApi'; |
||||
import { setGrafanaPromRules } from '../mocks/server/configure'; |
||||
import { rulerRuleType } from '../utils/rules'; |
||||
import { intervalToSeconds } from '../utils/time'; |
||||
|
||||
import { GrafanaGroupLoader, matchRules } from './GrafanaGroupLoader'; |
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false })); |
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false })); |
||||
|
||||
setupMswServer(); |
||||
|
||||
const ui = { |
||||
ruleItem: (ruleName: string) => byRole('treeitem', { name: ruleName }), |
||||
ruleStatus: (status: string) => byTitle(status), |
||||
ruleLink: (ruleName: string) => byRole('link', { name: ruleName }), |
||||
editButton: () => byRole('link', { name: 'Edit' }), |
||||
moreButton: () => byRole('button', { name: 'More' }), |
||||
}; |
||||
|
||||
describe('GrafanaGroupLoader', () => { |
||||
it('should render rule with url when ruler and prom rule exist', async () => { |
||||
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]); |
||||
|
||||
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup); |
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />); |
||||
|
||||
const [rule1] = grafanaRulerGroup.rules; |
||||
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find(); |
||||
|
||||
const ruleStatus = ui.ruleStatus('Normal').get(ruleListItem); |
||||
expect(ruleStatus).toBeInTheDocument(); |
||||
|
||||
const ruleLink = ui.ruleLink(rule1.grafana_alert.title).get(ruleListItem); |
||||
expect(ruleLink).toHaveAttribute('href', `/alerting/grafana/${rule1.grafana_alert.uid}/view`); |
||||
}); |
||||
|
||||
it('should render rule with url and creating state when only ruler rule exists', async () => { |
||||
setGrafanaPromRules([]); |
||||
|
||||
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup); |
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />); |
||||
|
||||
const [rule1] = grafanaRulerGroup.rules; |
||||
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find(); |
||||
|
||||
const creatingIcon = ui.ruleStatus('Creating').get(ruleListItem); |
||||
expect(creatingIcon).toBeInTheDocument(); |
||||
|
||||
const ruleLink = ui.ruleLink(rule1.grafana_alert.title).get(ruleListItem); |
||||
expect(ruleLink).toHaveAttribute('href', `/alerting/grafana/${rule1.grafana_alert.uid}/view`); |
||||
}); |
||||
|
||||
it('should render delete rule operation list item when only prom rule exists', async () => { |
||||
const promOnlyGroup: GrafanaPromRuleGroupDTO = { |
||||
...rulerGroupToPromGroup(grafanaRulerGroup), |
||||
name: 'prom-only-group', |
||||
}; |
||||
|
||||
setGrafanaPromRules([promOnlyGroup]); |
||||
|
||||
const groupIdentifier = getGroupIdentifier(promOnlyGroup); |
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />); |
||||
|
||||
const [rule1] = promOnlyGroup.rules; |
||||
const promRule = await ui.ruleItem(rule1.name).find(); |
||||
|
||||
const deletingIcon = ui.ruleStatus('Deleting').get(promRule); |
||||
expect(deletingIcon).toBeInTheDocument(); |
||||
|
||||
expect(ui.editButton().query(promRule)).not.toBeInTheDocument(); |
||||
expect(ui.moreButton().query(promRule)).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('matchRules', () => { |
||||
it('should return matches for all items and have empty promOnlyRules if all rules are matched by uid', () => { |
||||
const rulerRules = [ |
||||
mockGrafanaRulerRule({ uid: '1' }), |
||||
mockGrafanaRulerRule({ uid: '2' }), |
||||
mockGrafanaRulerRule({ uid: '3' }), |
||||
]; |
||||
|
||||
const promRules = rulerRules.map(rulerRuleToPromRule); |
||||
|
||||
const { matches, promOnlyRules } = matchRules(promRules, rulerRules); |
||||
|
||||
expect(matches.size).toBe(rulerRules.length); |
||||
expect(promOnlyRules).toHaveLength(0); |
||||
|
||||
for (const [rulerRule, promRule] of matches) { |
||||
expect(rulerRule.grafana_alert.uid).toBe(promRule.uid); |
||||
} |
||||
}); |
||||
|
||||
it('should return unmatched prometheus rules in promOnlyRules array', () => { |
||||
const rulerRules = [mockGrafanaRulerRule({ uid: '1' }), mockGrafanaRulerRule({ uid: '2' })]; |
||||
|
||||
const matchingPromRules = rulerRules.map(rulerRuleToPromRule); |
||||
const unmatchedPromRules = [mockGrafanaPromAlertingRule({ uid: '3' }), mockGrafanaPromAlertingRule({ uid: '4' })]; |
||||
|
||||
const allPromRules = [...matchingPromRules, ...unmatchedPromRules]; |
||||
const { matches, promOnlyRules } = matchRules(allPromRules, rulerRules); |
||||
|
||||
expect(matches.size).toBe(rulerRules.length); |
||||
expect(promOnlyRules).toHaveLength(unmatchedPromRules.length); |
||||
expect(promOnlyRules).toEqual(expect.arrayContaining(unmatchedPromRules)); |
||||
}); |
||||
|
||||
it('should not include ruler rules in matches if they have no prometheus counterpart', () => { |
||||
const rulerRules = [ |
||||
mockGrafanaRulerRule({ uid: '1' }), |
||||
mockGrafanaRulerRule({ uid: '2' }), |
||||
mockGrafanaRulerRule({ uid: '3' }), |
||||
]; |
||||
|
||||
// Only create prom rule for the second ruler rule
|
||||
const promRules = [rulerRuleToPromRule(rulerRules[1])]; |
||||
|
||||
const { matches, promOnlyRules } = matchRules(promRules, rulerRules); |
||||
|
||||
expect(matches.size).toBe(1); |
||||
expect(promOnlyRules).toHaveLength(0); |
||||
|
||||
// Verify that only the second ruler rule is in matches
|
||||
expect(matches.has(rulerRules[0])).toBe(false); |
||||
expect(matches.get(rulerRules[1])).toBe(promRules[0]); |
||||
expect(matches.has(rulerRules[2])).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
function rulerGroupToPromGroup(group: RulerRuleGroupDTO<RulerGrafanaRuleDTO>): GrafanaPromRuleGroupDTO { |
||||
return { |
||||
folderUid: group.name, |
||||
name: group.name, |
||||
file: group.name, |
||||
rules: group.rules.map<GrafanaPromRuleDTO>((r) => rulerRuleToPromRule(r)), |
||||
interval: intervalToSeconds(group.interval ?? '1m'), |
||||
}; |
||||
} |
||||
|
||||
function rulerRuleToPromRule(rule: RulerGrafanaRuleDTO): GrafanaPromRuleDTO { |
||||
return { |
||||
name: rule.grafana_alert.title, |
||||
query: JSON.stringify(rule.grafana_alert.data), |
||||
uid: rule.grafana_alert.uid, |
||||
folderUid: rule.grafana_alert.namespace_uid, |
||||
health: 'ok', |
||||
state: PromAlertingRuleState.Inactive, |
||||
type: rulerRuleType.grafana.alertingRule(rule) ? PromRuleType.Alerting : PromRuleType.Recording, |
||||
}; |
||||
} |
||||
|
||||
function getGroupIdentifier( |
||||
group: RulerRuleGroupDTO<RulerGrafanaRuleDTO> | GrafanaPromRuleGroupDTO |
||||
): GrafanaRuleGroupIdentifier { |
||||
return { |
||||
groupName: group.name, |
||||
namespace: { uid: grafanaRulerNamespace.uid }, |
||||
groupOrigin: 'grafana', |
||||
}; |
||||
} |
@ -0,0 +1,158 @@ |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { Alert } from '@grafana/ui'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; |
||||
import { GrafanaPromRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi'; |
||||
import { prometheusApi } from '../api/prometheusApi'; |
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; |
||||
import { GrafanaRulesSource } from '../utils/datasource'; |
||||
|
||||
import { GrafanaRuleListItem } from './GrafanaRuleLoader'; |
||||
import { RuleOperationListItem } from './components/AlertRuleListItem'; |
||||
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; |
||||
import { RuleOperation } from './components/RuleListIcon'; |
||||
|
||||
const { useGetGrafanaRulerGroupQuery } = alertRuleApi; |
||||
const { useGetGrafanaGroupsQuery } = prometheusApi; |
||||
|
||||
export interface GrafanaGroupLoaderProps { |
||||
groupIdentifier: GrafanaRuleGroupIdentifier; |
||||
namespaceName: string; |
||||
/** |
||||
* Used to display the same number of skeletons as there are rules |
||||
* The number of rules is typically known from paginated Prometheus response |
||||
* Ruler response might contain different number of rules, but in most cases what we get from Prometheus is fine |
||||
*/ |
||||
expectedRulesCount?: number; |
||||
} |
||||
|
||||
/** |
||||
* Loads an evaluation group from Prometheus and Ruler endpoints. |
||||
* Displays a loading skeleton while the data is being fetched. |
||||
* Polls the Prometheus endpoint every 20 seconds to refresh the data. |
||||
*/ |
||||
export function GrafanaGroupLoader({ |
||||
groupIdentifier, |
||||
namespaceName, |
||||
expectedRulesCount = 3, // 3 is a random number. Usually we get the number of rules from Prometheus response
|
||||
}: GrafanaGroupLoaderProps) { |
||||
const { data: promResponse, isLoading: isPromResponseLoading } = useGetGrafanaGroupsQuery( |
||||
{ |
||||
folderUid: groupIdentifier.namespace.uid, |
||||
groupName: groupIdentifier.groupName, |
||||
}, |
||||
{ pollingInterval: RULE_LIST_POLL_INTERVAL_MS } |
||||
); |
||||
const { data: rulerResponse, isLoading: isRulerGroupLoading } = useGetGrafanaRulerGroupQuery({ |
||||
folderUid: groupIdentifier.namespace.uid, |
||||
groupName: groupIdentifier.groupName, |
||||
}); |
||||
|
||||
const { matches, promOnlyRules } = useMemo(() => { |
||||
const promRules = promResponse?.data.groups.at(0)?.rules ?? []; |
||||
const rulerRules = rulerResponse?.rules ?? []; |
||||
|
||||
return matchRules(promRules, rulerRules); |
||||
}, [promResponse, rulerResponse]); |
||||
|
||||
const isLoading = isPromResponseLoading || isRulerGroupLoading; |
||||
if (isLoading) { |
||||
return ( |
||||
<> |
||||
{Array.from({ length: expectedRulesCount }).map((_, index) => ( |
||||
<AlertRuleListItemSkeleton key={index} /> |
||||
))} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
if (!rulerResponse || !promResponse) { |
||||
return ( |
||||
<Alert |
||||
title={t( |
||||
'alerting.group-loader.group-load-failed', |
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}', |
||||
{ groupName: groupIdentifier.groupName, namespaceName } |
||||
)} |
||||
severity="error" |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{rulerResponse.rules.map((rulerRule) => { |
||||
const promRule = matches.get(rulerRule); |
||||
|
||||
if (!promRule) { |
||||
return ( |
||||
<GrafanaRuleListItem |
||||
key={rulerRule.grafana_alert.uid} |
||||
rule={promRule} |
||||
rulerRule={rulerRule} |
||||
groupIdentifier={groupIdentifier} |
||||
namespaceName={namespaceName} |
||||
operation={RuleOperation.Creating} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<GrafanaRuleListItem |
||||
key={promRule.uid} |
||||
rule={promRule} |
||||
rulerRule={rulerRule} |
||||
groupIdentifier={groupIdentifier} |
||||
namespaceName={namespaceName} |
||||
/> |
||||
); |
||||
})} |
||||
{promOnlyRules.map((rule) => ( |
||||
<RuleOperationListItem |
||||
key={rule.uid} |
||||
name={rule.name} |
||||
namespace={namespaceName} |
||||
group={groupIdentifier.groupName} |
||||
rulesSource={GrafanaRulesSource} |
||||
application="grafana" |
||||
operation={RuleOperation.Deleting} |
||||
/> |
||||
))} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
interface MatchingResult { |
||||
matches: Map<RulerGrafanaRuleDTO, GrafanaPromRuleDTO>; |
||||
/** |
||||
* Rules that were already removed from the Ruler but the changes has not been yet propagated to Prometheus |
||||
*/ |
||||
promOnlyRules: GrafanaPromRuleDTO[]; |
||||
} |
||||
|
||||
export function matchRules( |
||||
promRules: GrafanaPromRuleDTO[], |
||||
rulerRules: RulerGrafanaRuleDTO[] |
||||
): Readonly<MatchingResult> { |
||||
const promRulesMap = new Map(promRules.map((rule) => [rule.uid, rule])); |
||||
|
||||
const matchingResult = rulerRules.reduce<MatchingResult>( |
||||
(acc, rulerRule) => { |
||||
const { matches } = acc; |
||||
const promRule = promRulesMap.get(rulerRule.grafana_alert.uid); |
||||
if (promRule) { |
||||
matches.set(rulerRule, promRule); |
||||
promRulesMap.delete(rulerRule.grafana_alert.uid); |
||||
} |
||||
return acc; |
||||
}, |
||||
{ matches: new Map(), promOnlyRules: [] } |
||||
); |
||||
|
||||
matchingResult.promOnlyRules.push(...promRulesMap.values()); |
||||
|
||||
return matchingResult; |
||||
} |
@ -0,0 +1,87 @@ |
||||
import { css, keyframes } from '@emotion/css'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; |
||||
|
||||
interface GroupStatusProps { |
||||
status: 'deleting'; // We don't support other statuses yet
|
||||
} |
||||
|
||||
export function GroupStatus({ status }: GroupStatusProps) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.container}> |
||||
<div className={styles.loader} /> |
||||
{status === 'deleting' && ( |
||||
<Tooltip content="The group is being deleted"> |
||||
<div className={styles.iconWrapper}> |
||||
<Icon name="trash-alt" size="sm" /> |
||||
</div> |
||||
</Tooltip> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const rotation = keyframes({ |
||||
'0%': { |
||||
transform: 'rotate(0deg)', |
||||
}, |
||||
'100%': { |
||||
transform: 'rotate(360deg)', |
||||
}, |
||||
}); |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
container: css({ |
||||
position: 'relative', |
||||
display: 'inline-flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
margin: theme.spacing(0.5), |
||||
}), |
||||
|
||||
loader: css({ |
||||
position: 'absolute', |
||||
inset: `-${theme.spacing(0.5)}`, |
||||
border: '2px solid #FFF', |
||||
borderRadius: theme.shape.radius.circle, |
||||
boxSizing: 'border-box', |
||||
[theme.transitions.handleMotion('no-preference')]: { |
||||
animationName: rotation, |
||||
animationIterationCount: 'infinite', |
||||
animationDuration: '1s', |
||||
animationTimingFunction: 'linear', |
||||
}, |
||||
|
||||
'&::after': { |
||||
content: '""', |
||||
boxSizing: 'border-box', |
||||
position: 'absolute', |
||||
left: '50%', |
||||
top: '50%', |
||||
transform: 'translate(-50%, -50%)', |
||||
width: 'calc(100% + 4px)', |
||||
height: 'calc(100% + 4px)', |
||||
borderRadius: theme.shape.radius.circle, |
||||
border: '2px solid transparent', |
||||
borderBottomColor: theme.colors.action.selectedBorder, |
||||
}, |
||||
}), |
||||
|
||||
iconWrapper: css({ |
||||
position: 'relative', |
||||
zIndex: 1, |
||||
display: 'flex', |
||||
}), |
||||
|
||||
'@keyframes rotation': { |
||||
'0%': { |
||||
transform: 'rotate(0deg)', |
||||
}, |
||||
'100%': { |
||||
transform: 'rotate(360deg)', |
||||
}, |
||||
}, |
||||
}); |
@ -0,0 +1,343 @@ |
||||
import { alertingFactory } from '../mocks/server/db'; |
||||
|
||||
import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching'; |
||||
|
||||
describe('getMatchingRulerRule', () => { |
||||
it('should match rule by unique name', () => { |
||||
// Create a ruler rule group with a single rule
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' }); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule] }); |
||||
|
||||
// Create a matching prom rule with same name
|
||||
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' }); |
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule); |
||||
expect(match).toBe(rulerRule); |
||||
}); |
||||
|
||||
it('should not match when names are different', () => { |
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'test-rule-1', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule] }); |
||||
|
||||
// Create a prom rule with different name but same labels/annotations
|
||||
const promRule = alertingFactory.prometheus.rule.build({ |
||||
name: 'test-rule-2', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule); |
||||
expect(match).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should match by labels and annotations when multiple rules have same name', () => { |
||||
// Create two ruler rules with same name but different labels
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'different' }, |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); |
||||
|
||||
// Create a matching prom rule with same name and matching labels
|
||||
const promRule = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule); |
||||
expect(match).toBe(rulerRule1); |
||||
}); |
||||
|
||||
it('should match by query when multiple rules have same name and labels', () => { |
||||
// Create two ruler rules with same name and labels but different queries
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
expr: 'up == 1', |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
expr: 'up == 0', |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); |
||||
|
||||
// Create a matching prom rule with same name, labels, and query
|
||||
const promRule = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
query: 'up == 1', |
||||
}); |
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule); |
||||
expect(match).toBe(rulerRule1); |
||||
}); |
||||
|
||||
it('should return undefined when rules differ only in the query part', () => { |
||||
// Create two ruler rules with same name but different labels and queries
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
expr: 'up == 1', |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'different' }, |
||||
expr: 'up == 0', |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); |
||||
|
||||
// Create a prom rule with same name but non-matching labels and query
|
||||
const promRule = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'error' }, |
||||
annotations: { summary: 'other' }, |
||||
query: 'up == 2', |
||||
}); |
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule); |
||||
expect(match).toBeUndefined(); |
||||
}); |
||||
}); |
||||
|
||||
describe('getMatchingPromRule', () => { |
||||
it('should match rule by unique name', () => { |
||||
// Create a prom rule group with a single rule
|
||||
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' }); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule] }); |
||||
|
||||
// Create a matching ruler rule with same name
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' }); |
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule); |
||||
expect(match).toBe(promRule); |
||||
}); |
||||
|
||||
it('should not match when names are different', () => { |
||||
const promRule = alertingFactory.prometheus.rule.build({ |
||||
name: 'test-rule-1', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule] }); |
||||
|
||||
// Create a ruler rule with different name but same labels/annotations
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'test-rule-2', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule); |
||||
expect(match).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should match by labels and annotations when multiple rules have same name', () => { |
||||
// Create two prom rules with same name but different labels
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'different' }, |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); |
||||
|
||||
// Create a matching ruler rule with same name and matching labels
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule); |
||||
expect(match).toBe(promRule1); |
||||
}); |
||||
|
||||
it('should match by query when multiple rules have same name and labels', () => { |
||||
// Create two prom rules with same name and labels but different queries
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
query: 'up == 1', |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
query: 'up == 0', |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); |
||||
|
||||
// Create a matching ruler rule with same name, labels, and expression
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
expr: 'up == 1', |
||||
}); |
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule); |
||||
expect(match).toBe(promRule1); |
||||
}); |
||||
|
||||
it('should return undefined when rules differ only in the query part', () => { |
||||
// Create two prom rules with same name but different labels and queries
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
query: 'up == 1', |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'same-name', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'different' }, |
||||
query: 'up == 0', |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); |
||||
|
||||
// Create a ruler rule with same name but non-matching labels and expression
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'same-name', |
||||
labels: { severity: 'error' }, |
||||
annotations: { summary: 'other' }, |
||||
expr: 'up == 2', |
||||
}); |
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule); |
||||
expect(match).toBeUndefined(); |
||||
}); |
||||
}); |
||||
|
||||
describe('matchRulesGroup', () => { |
||||
it('should match all rules when both groups have the same rules', () => { |
||||
// Create ruler rules
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); |
||||
|
||||
// Create matching prom rules
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
annotations: { summary: 'test' }, |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); |
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup); |
||||
|
||||
// All rules should be matched
|
||||
expect(result.matches.size).toBe(2); |
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1); |
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2); |
||||
expect(result.promOnlyRules).toHaveLength(0); |
||||
}); |
||||
|
||||
it('should handle ruler group having more rules than prom group', () => { |
||||
// Create ruler rules (3 rules)
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
}); |
||||
const rulerRule3 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-3', |
||||
labels: { severity: 'error' }, |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] }); |
||||
|
||||
// Create matching prom rules (only 2 rules)
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] }); |
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup); |
||||
|
||||
// Only 2 rules should be matched
|
||||
expect(result.matches.size).toBe(2); |
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1); |
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2); |
||||
expect(result.matches.get(rulerRule3)).toBeUndefined(); |
||||
expect(result.promOnlyRules).toHaveLength(0); |
||||
}); |
||||
|
||||
it('should handle prom group having more rules than ruler group', () => { |
||||
// Create ruler rules (2 rules)
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
}); |
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({ |
||||
alert: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
}); |
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] }); |
||||
|
||||
// Create matching prom rules (3 rules)
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-1', |
||||
labels: { severity: 'warning' }, |
||||
}); |
||||
const promRule2 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-2', |
||||
labels: { severity: 'critical' }, |
||||
}); |
||||
const promRule3 = alertingFactory.prometheus.rule.build({ |
||||
name: 'rule-3', |
||||
labels: { severity: 'error' }, |
||||
}); |
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2, promRule3] }); |
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup); |
||||
|
||||
// 2 rules should be matched, 1 should be in promOnlyRules
|
||||
expect(result.matches.size).toBe(2); |
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1); |
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2); |
||||
expect(result.promOnlyRules).toHaveLength(1); |
||||
expect(result.promOnlyRules[0]).toBe(promRule3); |
||||
}); |
||||
}); |
@ -0,0 +1,93 @@ |
||||
import { Rule } from 'app/types/unified-alerting'; |
||||
import { |
||||
PromRuleDTO, |
||||
PromRuleGroupDTO, |
||||
RulerCloudRuleDTO, |
||||
RulerRuleDTO, |
||||
RulerRuleGroupDTO, |
||||
} from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { getPromRuleFingerprint, getRulerRuleFingerprint } from '../utils/rule-id'; |
||||
import { getRuleName } from '../utils/rules'; |
||||
|
||||
export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>, rule: Rule) { |
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = rulerRuleGroup.rules.filter((r) => getRuleName(r) === rule.name); |
||||
if (rulesByName.length === 1) { |
||||
return rulesByName[0]; |
||||
} |
||||
|
||||
// If we don't have a unique rule name, try to compare by labels and annotations
|
||||
const rulesByLabelsAndAnnotations = rulesByName.filter((r) => { |
||||
return getRulerRuleFingerprint(r, false).join('-') === getPromRuleFingerprint(rule, false).join('-'); |
||||
}); |
||||
|
||||
if (rulesByLabelsAndAnnotations.length === 1) { |
||||
return rulesByLabelsAndAnnotations[0]; |
||||
} |
||||
|
||||
// As a last resort, compare including the query
|
||||
const rulesByLabelsAndAnnotationsAndQuery = rulesByName.filter((r) => { |
||||
return getRulerRuleFingerprint(r, true).join('-') === getPromRuleFingerprint(rule, true).join('-'); |
||||
}); |
||||
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length === 1) { |
||||
return rulesByLabelsAndAnnotationsAndQuery[0]; |
||||
} |
||||
|
||||
return undefined; |
||||
} |
||||
|
||||
export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO<PromRuleDTO>, rule: RulerCloudRuleDTO) { |
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = promRuleGroup.rules.filter((r) => r.name === getRuleName(rule)); |
||||
if (rulesByName.length === 1) { |
||||
return rulesByName[0]; |
||||
} |
||||
|
||||
// If we don't have a unique rule name, try to compare by labels and annotations
|
||||
const rulesByLabelsAndAnnotations = rulesByName.filter((r) => { |
||||
return getPromRuleFingerprint(r, false).join('-') === getRulerRuleFingerprint(rule, false).join('-'); |
||||
}); |
||||
|
||||
if (rulesByLabelsAndAnnotations.length === 1) { |
||||
return rulesByLabelsAndAnnotations[0]; |
||||
} |
||||
|
||||
// As a last resort, compare including the query
|
||||
const rulesByLabelsAndAnnotationsAndQuery = rulesByName.filter((r) => { |
||||
return getPromRuleFingerprint(r, true).join('-') === getRulerRuleFingerprint(rule, true).join('-'); |
||||
}); |
||||
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length === 1) { |
||||
return rulesByLabelsAndAnnotationsAndQuery[0]; |
||||
} |
||||
|
||||
return undefined; |
||||
} |
||||
|
||||
interface GroupMatchingResult { |
||||
matches: Map<RulerRuleDTO, PromRuleDTO>; |
||||
promOnlyRules: PromRuleDTO[]; |
||||
} |
||||
|
||||
export function matchRulesGroup( |
||||
rulerGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>, |
||||
promGroup: PromRuleGroupDTO<PromRuleDTO> |
||||
): GroupMatchingResult { |
||||
const matchingResult = rulerGroup.rules.reduce( |
||||
(acc, rulerRule) => { |
||||
const { matches, unmatchedPromRules } = acc; |
||||
|
||||
const promRule = getMatchingPromRule(promGroup, rulerRule); |
||||
if (promRule) { |
||||
matches.set(rulerRule, promRule); |
||||
unmatchedPromRules.delete(promRule); |
||||
} |
||||
return acc; |
||||
}, |
||||
{ matches: new Map<RulerRuleDTO, PromRuleDTO>(), unmatchedPromRules: new Set(promGroup.rules) } |
||||
); |
||||
|
||||
return { matches: matchingResult.matches, promOnlyRules: Array.from(matchingResult.unmatchedPromRules) }; |
||||
} |
Loading…
Reference in new issue