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
Konrad Lalik 2 months ago committed by GitHub
parent 80fb267cd5
commit 3e15459d20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 16
      .betterer.results
  2. 1
      packages/grafana-data/src/types/icon.ts
  3. 40
      public/app/features/alerting/unified/api/alertRuleApi.ts
  4. 31
      public/app/features/alerting/unified/api/alertRuleModel.ts
  5. 38
      public/app/features/alerting/unified/api/prometheusApi.ts
  6. 2
      public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
  7. 80
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
  8. 2
      public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
  9. 113
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
  10. 193
      public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx
  11. 451
      public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
  12. 280
      public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx
  13. 2
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
  14. 9
      public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
  15. 2
      public/app/features/alerting/unified/featureToggles.ts
  16. 2
      public/app/features/alerting/unified/group-details/GroupEditPage.tsx
  17. 43
      public/app/features/alerting/unified/group-details/validation.ts
  18. 40
      public/app/features/alerting/unified/hooks/useCombinedRule.ts
  19. 10
      public/app/features/alerting/unified/hooks/useHasRuler.ts
  20. 48
      public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts
  21. 5
      public/app/features/alerting/unified/mockApi.ts
  22. 12
      public/app/features/alerting/unified/mocks.ts
  23. 83
      public/app/features/alerting/unified/mocks/grafanaRulerApi.ts
  24. 6
      public/app/features/alerting/unified/mocks/server/configure.ts
  25. 97
      public/app/features/alerting/unified/mocks/server/db.ts
  26. 40
      public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts
  27. 21
      public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts
  28. 156
      public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx
  29. 223
      public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx
  30. 79
      public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx
  31. 101
      public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx
  32. 31
      public/app/features/alerting/unified/rule-list/FilterView.test.tsx
  33. 17
      public/app/features/alerting/unified/rule-list/FilterView.tsx
  34. 181
      public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx
  35. 158
      public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx
  36. 69
      public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx
  37. 40
      public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx
  38. 44
      public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx
  39. 96
      public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx
  40. 64
      public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
  41. 80
      public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx
  42. 2
      public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx
  43. 87
      public/app/features/alerting/unified/rule-list/components/GroupStatus.tsx
  44. 19
      public/app/features/alerting/unified/rule-list/components/ListGroup.tsx
  45. 14
      public/app/features/alerting/unified/rule-list/components/ListItem.tsx
  46. 2
      public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
  47. 149
      public/app/features/alerting/unified/rule-list/components/RuleGroupActionsMenu.tsx
  48. 90
      public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx
  49. 114
      public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
  50. 10
      public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts
  51. 343
      public/app/features/alerting/unified/rule-list/ruleMatching.test.ts
  52. 93
      public/app/features/alerting/unified/rule-list/ruleMatching.ts
  53. 15
      public/app/features/alerting/unified/utils/datasource.ts
  54. 22
      public/app/features/alerting/unified/utils/groupIdentifier.ts
  55. 9
      public/app/features/alerting/unified/utils/misc.ts
  56. 16
      public/app/features/alerting/unified/utils/navigation.ts
  57. 24
      public/app/features/alerting/unified/utils/rule-id.ts
  58. 9
      public/app/features/alerting/unified/utils/rules.ts
  59. 27
      public/locales/en-US/grafana.json

@ -2073,19 +2073,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
],
"public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
@ -2128,9 +2115,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]

@ -114,6 +114,7 @@ export const availableIconsIndex = {
'file-copy-alt': true,
'file-download': true,
'file-edit-alt': true,
'file-export': true,
'file-landscape-alt': true,
filter: true,
flip: true,

@ -3,12 +3,7 @@ import { set } from 'lodash';
import { RelativeTimeRange } from '@grafana/data';
import { t } from 'app/core/internationalization';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import {
GrafanaRuleGroupIdentifier,
RuleIdentifier,
RuleNamespace,
RulerDataSourceConfig,
} from 'app/types/unified-alerting';
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import {
AlertQuery,
Annotations,
@ -29,6 +24,7 @@ import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, isGrafanaRulesSource }
import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier, rulerRuleType } from '../utils/rules';
import { RulerGroupUpdatedResponse } from './alertRuleModel';
import { WithNotificationOptions, alertingApi } from './alertingApi';
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
import {
@ -91,14 +87,6 @@ interface ExportRulesParams {
ruleUid?: string;
}
export interface AlertGroupUpdated {
message: string;
/**
* UIDs of rules updated from this request
*/
updated: string[];
}
export const alertRuleApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
preview: build.mutation<
@ -272,16 +260,18 @@ export const alertRuleApi = alertingApi.injectEndpoints({
],
}),
getGrafanaRulerGroup: build.query<RulerRuleGroupDTO<RulerGrafanaRuleDTO>, GrafanaRuleGroupIdentifier>({
query: ({ namespace, groupName }) => {
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespaceGroup(namespace.uid, groupName);
return { url: path, params };
},
providesTags: (_result, _error, { namespace, groupName }) => [
{ type: 'RuleGroup', id: `grafana/${namespace.uid}/${groupName}` },
{ type: 'RuleNamespace', id: `grafana/${namespace.uid}` },
],
}),
getGrafanaRulerGroup: build.query<RulerRuleGroupDTO<RulerGrafanaRuleDTO>, { folderUid: string; groupName: string }>(
{
query: ({ folderUid, groupName }) => {
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespaceGroup(folderUid, groupName);
return { url: path, params };
},
providesTags: (_result, _error, { folderUid, groupName }) => [
{ type: 'RuleGroup', id: `grafana/${folderUid}/${groupName}` },
{ type: 'RuleNamespace', id: `grafana/${folderUid}` },
],
}
),
deleteRuleGroupFromNamespace: build.mutation<
RulerRuleGroupDTO,
@ -313,7 +303,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({
}),
upsertRuleGroupForNamespace: build.mutation<
AlertGroupUpdated,
RulerGroupUpdatedResponse,
WithNotificationOptions<{
rulerConfig: RulerDataSourceConfig;
namespace: string;

@ -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;
}

@ -2,7 +2,7 @@ import { GrafanaPromRuleGroupDTO, PromRuleDTO, PromRuleGroupDTO } from 'app/type
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { alertingApi } from './alertingApi';
import { WithNotificationOptions, alertingApi } from './alertingApi';
import { normalizeRuleGroup } from './prometheus';
export interface PromRulesResponse<TRuleGroup> {
@ -15,7 +15,7 @@ export interface PromRulesResponse<TRuleGroup> {
error?: string;
}
interface PromRulesOptions {
type PromRulesOptions = WithNotificationOptions<{
ruleSource: { uid: string };
namespace?: string;
groupName?: string;
@ -23,9 +23,10 @@ interface PromRulesOptions {
groupLimit?: number;
excludeAlerts?: boolean;
groupNextToken?: string;
}
}>;
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace'> & {
folderUid?: string;
dashboardUid?: string;
panelId?: number;
};
@ -33,20 +34,33 @@ type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
export const prometheusApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getGroups: build.query<PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>, PromRulesOptions>({
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => {
query: ({
ruleSource,
namespace,
groupName,
ruleName,
groupLimit,
excludeAlerts,
groupNextToken,
notificationOptions,
}) => {
if (ruleSource.uid === GRAFANA_RULES_SOURCE_NAME) {
throw new Error('Please use getGrafanaGroups endpoint for grafana rules');
}
return {
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
params: {
'file[]': namespace,
'group[]': groupName,
'rule[]': ruleName,
file: namespace, // Mimir
'file[]': namespace, // Prometheus
rule_group: groupName, // Mimir
'rule_group[]': groupName, // Prometheus
rule_name: ruleName, // Mimir
'rule_name[]': ruleName, // Prometheus
exclude_alerts: excludeAlerts?.toString(),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
},
notificationOptions,
};
},
transformResponse: (response: PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>) => {
@ -54,12 +68,12 @@ export const prometheusApi = alertingApi.injectEndpoints({
},
}),
getGrafanaGroups: build.query<PromRulesResponse<GrafanaPromRuleGroupDTO>, GrafanaPromRulesOptions>({
query: ({ namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
query: ({ folderUid, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
url: `api/prometheus/grafana/api/v1/rules`,
params: {
'file[]': namespace,
'group[]': groupName,
'rule[]': ruleName,
folder_uid: folderUid,
rule_group: groupName,
rule_name: ruleName,
exclude_alerts: excludeAlerts?.toString(),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,

@ -25,6 +25,7 @@ import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
import { evaluateEveryValidationOptions } from '../../group-details/validation';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
import { RuleFormValues } from '../../types/rule-form';
import {
@ -36,7 +37,6 @@ import {
import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
import { ProvisioningBadge } from '../Provisioning';
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom-v5-compat';
@ -28,6 +28,7 @@ import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-aler
import {
LogMessages,
logInfo,
logWarning,
trackAlertRuleFormCancelled,
trackAlertRuleFormError,
trackAlertRuleFormSaved,
@ -35,7 +36,12 @@ import {
trackNewGrafanaAlertRuleFormError,
trackNewGrafanaAlertRuleFormSavedSuccess,
} from '../../../Analytics';
import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
import {
GrafanaGroupUpdatedResponse,
RulerGroupUpdatedResponse,
isGrafanaGroupUpdatedResponse,
} from '../../../api/alertRuleModel';
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
import { useReturnTo } from '../../../hooks/useReturnTo';
@ -50,6 +56,7 @@ import {
isExpressionQueryInAlert,
} from '../../../rule-editor/formProcessing';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { rulesNav } from '../../../utils/navigation';
import {
MANUAL_ROUTING_KEY,
SIMPLIFIED_QUERY_EDITOR_KEY,
@ -77,10 +84,12 @@ type Props = {
};
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const alertingListViewV2 = shouldUseAlertingListViewV2();
export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const { redirectToDetailsPage } = useRedirectToDetailsPage();
const [showEditYaml, setShowEditYaml] = useState(false);
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
@ -169,12 +178,14 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
: getRuleGroupLocationFromFormValues(values);
const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
let saveResult: RulerGroupUpdatedResponse;
// @TODO move this to a hook too to make sure the logic here is tested for regressions?
if (!existing) {
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
storeInLocalStorageValues(values);
// save the rule to the rule group
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
saveResult = await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
// track the new Grafana-managed rule creation in the analytics
if (grafanaTypeRule) {
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
@ -188,7 +199,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
} else {
// when updating an existing rule
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
await updateRuleInRuleGroup.execute(
saveResult = await updateRuleInRuleGroup.execute(
ruleGroupIdentifier,
ruleIdentifier,
ruleDefinition,
@ -198,6 +209,15 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
}
const { dataSourceName, namespaceName, groupName } = targetRuleGroupIdentifier;
// V2 list is based on eventually consistent Prometheus API.
// When a new rule group is created it takes a while for the new rule group to be reflected in the V2 list.
// To avoid user confusion we redirect to the details page which is driven by a strongly consistent Ruler API..
if (alertingListViewV2) {
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
return;
}
if (exitOnSave) {
const returnToUrl = returnTo || getReturnToUrl(targetRuleGroupIdentifier, ruleDefinition);
@ -366,6 +386,58 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
);
};
function useRedirectToDetailsPage() {
const notifyApp = useAppNotification();
const redirectGrafanaRule = useCallback(
(saveResult: GrafanaGroupUpdatedResponse) => {
const newOrUpdatedRuleUid = saveResult.created?.at(0) || saveResult.updated?.at(0);
if (newOrUpdatedRuleUid) {
locationService.replace(
rulesNav.detailsPageLink('grafana', { uid: newOrUpdatedRuleUid, ruleSourceName: 'grafana' })
);
} else {
notifyApp.error(
'Cannot navigate to the new rule details page.',
'The rule was created but the UID is missing.'
);
logWarning('Cannot navigate to the new rule details page. The rule was created but the UID is missing.');
}
},
[notifyApp]
);
const redirectCloudRulerRule = useCallback((rule: RulerRuleDTO, groupId: RuleGroupIdentifier) => {
const { dataSourceName, namespaceName, groupName } = groupId;
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule);
locationService.replace(rulesNav.detailsPageLink(updatedRuleIdentifier.ruleSourceName, updatedRuleIdentifier));
}, []);
const redirectToDetailsPage = useCallback(
(
rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO,
groupId: RuleGroupIdentifier,
saveResult: RulerGroupUpdatedResponse
) => {
if (isGrafanaGroupUpdatedResponse(saveResult)) {
redirectGrafanaRule(saveResult);
return;
} else if (rulerRuleType.dataSource.rule(rule)) {
redirectCloudRulerRule(rule, groupId);
return;
}
logWarning(
'Cannot navigate to the new rule details page. The response is not a GrafanaGroupUpdatedResponse and ruleDefinition is not a Cloud Ruler rule.',
{ ruleFormType: rulerRuleType.dataSource.rule(rule) ? 'datasource' : 'grafana' }
);
},
[redirectGrafanaRule, redirectCloudRulerRule]
);
return { redirectToDetailsPage };
}
function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) {
const { dataSourceName, namespaceName, groupName } = groupId;

@ -16,7 +16,7 @@ import { createRelativeUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
interface Props {
promRule: Rule;
promRule?: Rule;
rulerRule?: RulerRuleDTO;
identifier: RuleIdentifier;
groupIdentifier: RuleGroupIdentifierV2;

@ -1,24 +1,45 @@
import { css } from '@emotion/css';
import { chain, isEmpty, truncate } from 'lodash';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { NavModelItem, UrlQueryValue } from '@grafana/data';
import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui';
import {
Alert,
LinkButton,
LoadingBar,
Stack,
TabContent,
Text,
TextLink,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { PageInfoItem } from 'app/core/components/Page/types';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans, t } from 'app/core/internationalization';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import { AlertInstanceTotalState, CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting';
import {
AlertInstanceTotalState,
CombinedRule,
RuleGroupIdentifierV2,
RuleHealth,
RuleIdentifier,
} from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
import { defaultPageNav } from '../../RuleViewer';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { usePrometheusCreationConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { isError, useAsync } from '../../hooks/useAsync';
import { useRuleLocation } from '../../hooks/useCombinedRule';
import { useHasRulerV2 } from '../../hooks/useHasRuler';
import { useRuleGroupConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
import { useReturnTo } from '../../hooks/useReturnTo';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { Annotation } from '../../utils/constants';
import { ruleIdentifierToRuleSourceIdentifier } from '../../utils/datasource';
import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc';
import { createListFilterLink } from '../../utils/navigation';
import {
@ -57,6 +78,9 @@ export enum ActiveTab {
}
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const alertingListViewV2 = shouldUseAlertingListViewV2();
const shouldUseConsistencyCheck = prometheusRulesPrimary || alertingListViewV2;
const RuleViewer = () => {
const { rule, identifier } = useAlertRule();
@ -118,7 +142,7 @@ const RuleViewer = () => {
</Stack>
}
>
{prometheusRulesPrimary && <PrometheusConsistencyCheck ruleIdentifier={identifier} />}
{shouldUseConsistencyCheck && <PrometheusConsistencyCheck ruleIdentifier={identifier} />}
<Stack direction="column" gap={2}>
{/* tabs and tab content */}
<TabContent>
@ -267,41 +291,64 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi
);
};
interface PrometheusConsistencyCheckProps {
ruleIdentifier: RuleIdentifier;
}
/**
* This component displays an Alert warning component if discovers inconsistencies between Prometheus and Ruler rules
* It will show loading indicator until the Prometheus and Ruler rule is consistent
* It will not show the warning if the rule is Grafana managed
*/
function PrometheusConsistencyCheck({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) {
const [ref, { width }] = useMeasure<HTMLDivElement>();
const { isConsistent, error } = usePrometheusCreationConsistencyCheck(ruleIdentifier);
const PrometheusConsistencyCheck = withErrorBoundary(
({ ruleIdentifier }: PrometheusConsistencyCheckProps) => {
const [ref, { width }] = useMeasure<HTMLDivElement>();
if (isConsistent) {
return null;
}
const { hasRuler } = useHasRulerV2(ruleIdentifierToRuleSourceIdentifier(ruleIdentifier));
const { result: ruleLocation } = useRuleLocation(ruleIdentifier);
if (error) {
return (
<Alert title="Unable to check the rule status" bottomSpacing={0} topSpacing={2}>
{stringifyErrorLike(error)}
</Alert>
);
}
const { waitForGroupConsistency, groupConsistent } = useRuleGroupConsistencyCheck();
return (
<Stack direction="column" gap={0} ref={ref}>
<LoadingBar width={width} />
<Alert
title={t('alerting.rule-viewer.prometheus-consistency-check.alert-title', 'Update in progress')}
severity="info"
>
<Trans i18nKey="alerting.rule-viewer.prometheus-consistency-check.alert-message">
Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view.
</Trans>
</Alert>
</Stack>
);
}
const [waitAction, waitState] = useAsync((groupIdentifier: RuleGroupIdentifierV2) => {
return waitForGroupConsistency(groupIdentifier);
});
useEffect(() => {
if (ruleLocation && hasRuler) {
waitAction.execute(ruleLocation.groupIdentifier);
}
}, [ruleLocation, hasRuler, waitAction]);
if (isError(waitState)) {
return (
<Alert title="Unable to check the rule status" bottomSpacing={0} topSpacing={2}>
{stringifyErrorLike(waitState.error)}
</Alert>
);
}
// If groupConsistent is undefined, it means that the rule is still being checked and we don't know if it's consistent or not
// To prevent the inconsistency banner from blinking, we only show it if groupConsistent is false
if (groupConsistent === false) {
return (
<Stack direction="column" gap={0} ref={ref}>
<LoadingBar width={width} />
<Alert
title={t('alerting.rule-viewer.prometheus-consistency-check.alert-title', 'Update in progress')}
severity="info"
>
<Trans i18nKey="alerting.rule-viewer.prometheus-consistency-check.alert-message">
Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list
view.
</Trans>
</Alert>
</Stack>
);
}
return null;
},
{ errorLogger: logError }
);
export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err';

@ -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),
}),
});

@ -93,7 +93,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
);
}
if (!rule.promRule) {
if (!rule.promRule && !rule.rulerRule) {
return null;
}

@ -8,7 +8,9 @@ import { AccessControlAction } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import * as analytics from '../../Analytics';
import { mockCombinedRule, mockDataSource } from '../../mocks';
import { setupMswServer } from '../../mockApi';
import { mockCombinedRule } from '../../mocks';
import { mimirDataSource } from '../../mocks/server/configure';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { RuleListGroupView } from './RuleListGroupView';
@ -25,6 +27,9 @@ setPluginLinksHook(() => ({
isLoading: false,
}));
setupMswServer();
const mimirDs = mimirDataSource();
describe('RuleListGroupView', () => {
describe('RBAC', () => {
it('Should display Grafana rules when the user has the alert rule read permission', async () => {
@ -119,7 +124,7 @@ function getGrafanaNamespace(): CombinedRuleNamespace {
function getCloudNamespace(): CombinedRuleNamespace {
return {
name: 'Cloud Test Namespace',
rulesSource: mockDataSource(),
rulesSource: mimirDs.dataSource,
groups: [
{
name: 'Prom group',

@ -4,6 +4,8 @@ import { isAdmin } from './utils/misc';
export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false;
export const shouldUseAlertingListViewV2 = () => config.featureToggles.alertingListViewV2 ?? false;
export const useGrafanaManagedRecordingRulesSupport = () =>
config.unifiedAlerting.recordingRulesEnabled && config.featureToggles.grafanaManagedRecordingRules;

@ -29,7 +29,6 @@ import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { EvaluationGroupQuickPick } from '../components/rule-editor/EvaluationGroupQuickPick';
import { evaluateEveryValidationOptions } from '../components/rules/EditRuleGroupModal';
import { useDeleteRuleGroup } from '../hooks/ruleGroup/useDeleteRuleGroup';
import { UpdateGroupDelta, useUpdateRuleGroup } from '../hooks/ruleGroup/useUpdateRuleGroup';
import { isLoading, useAsync } from '../hooks/useAsync';
@ -43,6 +42,7 @@ import { stringifyErrorLike } from '../utils/misc';
import { alertListPageLink, createListFilterLink, groups } from '../utils/navigation';
import { DraggableRulesTable } from './components/DraggableRulesTable';
import { evaluateEveryValidationOptions } from './validation';
type GroupEditPageRouteParams = {
dataSourceUid?: string;

@ -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';
}
},
});

@ -3,12 +3,19 @@ import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use';
import { isGrafanaRulesSource } from 'app/features/alerting/unified/utils/datasource';
import { CombinedRule, RuleIdentifier, RuleWithLocation, RulesSource } from 'app/types/unified-alerting';
import {
CombinedRule,
RuleGroupIdentifierV2,
RuleIdentifier,
RuleWithLocation,
RulesSource,
} from 'app/types/unified-alerting';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { getDataSourceByName } from '../utils/datasource';
import { groupIdentifier } from '../utils/groupIdentifier';
import * as ruleId from '../utils/rule-id';
import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
@ -170,6 +177,7 @@ export interface RuleLocation {
namespace: string;
group: string;
ruleName: string;
groupIdentifier: RuleGroupIdentifierV2;
}
export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocation> {
@ -189,15 +197,20 @@ export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<Ru
return useMemo(() => {
if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
return {
result: {
datasource: ruleIdentifier.ruleSourceName,
namespace: ruleIdentifier.namespace,
group: ruleIdentifier.groupName,
ruleName: ruleIdentifier.ruleName,
},
loading: false,
};
try {
return {
result: {
datasource: ruleIdentifier.ruleSourceName,
namespace: ruleIdentifier.namespace,
group: ruleIdentifier.groupName,
ruleName: ruleIdentifier.ruleName,
groupIdentifier: groupIdentifier.fromRuleIdentifier(ruleIdentifier),
} satisfies RuleLocation,
loading: false,
};
} catch (error) {
return { loading: false, error };
}
}
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
@ -215,7 +228,12 @@ export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<Ru
namespace: currentData.grafana_alert.namespace_uid,
group: currentData.grafana_alert.rule_group,
ruleName: currentData.grafana_alert.title,
},
groupIdentifier: {
namespace: { uid: currentData.grafana_alert.namespace_uid },
groupName: currentData.grafana_alert.rule_group,
groupOrigin: 'grafana',
},
} satisfies RuleLocation,
loading: false,
};
}

@ -1,4 +1,4 @@
import { RulesSource } from 'app/types/unified-alerting';
import { RulesSource, RulesSourceIdentifier } from 'app/types/unified-alerting';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { getRulesSourceName } from '../utils/datasource';
@ -6,6 +6,7 @@ import { getRulesSourceName } from '../utils/datasource';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
// datasource has ruler if the discovery api returns a rulerConfig
/** @deprecated use useHasRulerV2 instead */
export function useHasRuler(rulesSource: RulesSource) {
const rulesSourceName = getRulesSourceName(rulesSource);
@ -14,3 +15,10 @@ export function useHasRuler(rulesSource: RulesSource) {
return { hasRuler, rulerConfig: dsFeatures?.rulerConfig };
}
export function useHasRulerV2(rulesSource: RulesSourceIdentifier) {
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
const hasRuler = Boolean(dsFeatures?.rulerConfig);
return { hasRuler, rulerConfig: dsFeatures?.rulerConfig };
}

@ -1,5 +1,5 @@
import { zip } from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
CloudRuleIdentifier,
@ -76,6 +76,8 @@ export function useRuleGroupIsInSync() {
ruleSourceName: dsFeatures.name,
namespace: namespace,
groupName: ruleIdentifier.groupName,
limitAlerts: 0,
excludeAlerts: true,
};
const rulerParams: Parameters<typeof fetchRuleGroup>[0] = {
namespace,
@ -151,8 +153,9 @@ export function useRuleGroupIsInSync() {
export function useRuleGroupConsistencyCheck() {
const { isGroupInSync } = useRuleGroupIsInSync();
const [groupConsistent, setGroupConsistent] = useState<boolean | undefined>();
const consistencyInterval = useRef<number | undefined>();
const consistencyInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
useEffect(() => {
return () => {
@ -162,7 +165,7 @@ export function useRuleGroupConsistencyCheck() {
const clearConsistencyInterval = () => {
if (consistencyInterval.current) {
clearInterval(consistencyInterval.current);
clearTimeout(consistencyInterval.current);
consistencyInterval.current = undefined;
}
};
@ -186,37 +189,46 @@ export function useRuleGroupConsistencyCheck() {
});
const waitPromise = new Promise<void>((resolve, reject) => {
performance.mark('waitForGroupConsistency:started');
consistencyInterval.current = setInterval(() => {
function logWaitingTime() {
performance.mark('waitForGroupConsistency:finished');
const duration = performance.measure(
'waitForGroupConsistency',
'waitForGroupConsistency:started',
'waitForGroupConsistency:finished'
);
logMeasurement(
'alerting:wait-for-group-consistency',
{ duration: duration.duration },
{ groupOrigin: groupIdentifier.groupOrigin }
);
}
function checkGroupConsistency() {
isGroupInSync(groupIdentifier)
.then((inSync) => {
setGroupConsistent(inSync);
if (inSync) {
performance.mark('waitForGroupConsistency:finished');
const duration = performance.measure(
'waitForGroupConsistency',
'waitForGroupConsistency:started',
'waitForGroupConsistency:finished'
);
logMeasurement(
'alerting:wait-for-group-consistency',
{ duration: duration.duration },
{ groupOrigin: groupIdentifier.groupOrigin }
);
logWaitingTime();
clearConsistencyInterval();
resolve();
} else {
consistencyInterval.current = setTimeout(checkGroupConsistency, CONSISTENCY_CHECK_POOL_INTERVAL);
}
})
.catch((error) => {
clearConsistencyInterval();
reject(error);
});
}, CONSISTENCY_CHECK_POOL_INTERVAL);
}
performance.mark('waitForGroupConsistency:started');
checkGroupConsistency();
});
return Promise.race([timeoutPromise, waitPromise]);
}
return { waitForGroupConsistency };
return { waitForGroupConsistency, groupConsistent };
}
export function usePrometheusConsistencyCheck() {

@ -2,7 +2,6 @@ import { HttpResponse, http } from 'msw';
import { SetupServer, setupServer } from 'msw/node';
import { setBackendSrv } from '@grafana/runtime';
import { AlertGroupUpdated } from 'app/features/alerting/unified/api/alertRuleApi';
import allHandlers from 'app/features/alerting/unified/mocks/server/all-handlers';
import {
setupAlertmanagerConfigMapDefaultState,
@ -29,6 +28,8 @@ import {
} from '../../../plugins/datasource/alertmanager/types';
import { DashboardSearchItem } from '../../search/types';
import { RulerGroupUpdatedResponse } from './api/alertRuleModel';
type Configurator<T> = (builder: T) => T;
export class AlertmanagerConfigBuilder {
@ -173,7 +174,7 @@ export function mockAlertRuleApi(server: SetupServer) {
rulerRules: (dsName: string, response: RulerRulesConfigDTO) => {
server.use(http.get(`/api/ruler/${dsName}/api/v1/rules`, () => HttpResponse.json(response)));
},
updateRule: (dsName: string, response: AlertGroupUpdated) => {
updateRule: (dsName: string, response: RulerGroupUpdatedResponse) => {
server.use(http.post(`/api/ruler/${dsName}/api/v1/rules/:namespaceUid`, () => HttpResponse.json(response)));
},
rulerRuleGroup: (dsName: string, namespace: string, group: string, response: RulerRuleGroupDTO) => {

@ -43,6 +43,7 @@ import {
AlertQuery,
GrafanaAlertState,
GrafanaAlertStateDecision,
GrafanaPromAlertingRuleDTO,
GrafanaRuleDefinition,
PromAlertingRuleState,
PromRuleType,
@ -223,6 +224,17 @@ export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): Alert
};
};
export const mockGrafanaPromAlertingRule = (
partial: Partial<GrafanaPromAlertingRuleDTO> = {}
): GrafanaPromAlertingRuleDTO => {
return {
...mockPromAlertingRule(),
uid: 'mock-rule-uid-123',
folderUid: 'NAMESPACE_UID',
...partial,
};
};
export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {}): RulerGrafanaRuleDTO => {
return {
for: '',

@ -7,6 +7,7 @@ import {
PromRulesResponse,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { PREVIEW_URL, PROM_RULES_URL, PreviewResponse } from '../api/alertRuleApi';
@ -61,33 +62,93 @@ export const grafanaRulerRule: RulerGrafanaRuleDTO = {
},
};
export const grafanaRulerGroup: RulerRuleGroupDTO = {
export const grafanaRulerGroup: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
name: grafanaRulerGroupName,
interval: '1m',
rules: [grafanaRulerRule],
};
export const grafanaRulerGroup2: RulerRuleGroupDTO = {
export const grafanaRulerGroup2: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
name: grafanaRulerGroupName2,
interval: '1m',
rules: [grafanaRulerRule],
};
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = {
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
name: 'empty-group',
interval: '1m',
rules: [],
};
export const namespaceByUid: Record<string, { name: string; uid: string }> = {
[grafanaRulerNamespace.uid]: grafanaRulerNamespace,
[grafanaRulerNamespace2.uid]: grafanaRulerNamespace2,
};
// AKA Folder
interface GrafanaNamespace {
name: string;
uid: string;
}
export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
[grafanaRulerNamespace.uid]: [grafanaRulerGroup, grafanaRulerGroup2],
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
};
export class RulerTestDb {
private namespaces = new Map<string, string>(); // UID -> Name
private groupsByNamespaceUid = new Map<string, RulerRuleGroupDTO[]>();
constructor(groups: Iterable<[RulerRuleGroupDTO, GrafanaNamespace]> = []) {
for (const [group, namespace] of groups) {
this.addGroup(group, namespace);
}
}
addGroup(group: RulerRuleGroupDTO, namespace: GrafanaNamespace) {
if (!this.namespaces.has(namespace.uid)) {
this.namespaces.set(namespace.uid, namespace.name);
}
const namespaceGroups = this.groupsByNamespaceUid.get(namespace.uid);
if (!namespaceGroups) {
this.groupsByNamespaceUid.set(namespace.uid, [group]);
} else {
namespaceGroups.push(group);
}
}
getRulerConfig(): RulerRulesConfigDTO {
const config: RulerRulesConfigDTO = {};
for (const [namespaceUid, groups] of this.groupsByNamespaceUid) {
const namespaceName = this.namespaces.get(namespaceUid);
if (!namespaceName) {
throw new Error(`Namespace name for uid ${namespaceUid} not found`);
}
config[namespaceName] = groups;
}
return config;
}
getNamespace(uid: string): RulerRulesConfigDTO | undefined {
const namespaceGroups = this.groupsByNamespaceUid.get(uid);
if (!namespaceGroups) {
return undefined;
}
const namespaceName = this.namespaces.get(uid);
if (!namespaceName) {
throw new Error(`Namespace name for uid ${uid} not found`);
}
return { [namespaceName]: namespaceGroups };
}
getGroup(uid: string, groupName: string): RulerRuleGroupDTO | undefined {
const namespaceGroups = this.groupsByNamespaceUid.get(uid);
if (!namespaceGroups) {
return undefined;
}
return namespaceGroups.find((group) => group.name === groupName);
}
}
export const rulerTestDb = new RulerTestDb([
[grafanaRulerGroup, grafanaRulerNamespace],
[grafanaRulerGroup2, grafanaRulerNamespace],
[grafanaRulerEmptyGroup, grafanaRulerNamespace2],
]);
//-------------------- for alert history tests we reuse these constants --------------------
export const time_0 = 1718368710000;

@ -20,7 +20,7 @@ import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { PromRuleGroupDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources';
import { DataSourceType } from '../../utils/datasource';
@ -191,6 +191,10 @@ export function setPrometheusRules(ds: DataSourceLike, groups: PromRuleGroupDTO[
server.use(http.get(`/api/prometheus/${ds.uid}/api/v1/rules`, paginatedHandlerFor(groups)));
}
export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups)));
}
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
export const removePlugin = (pluginId: string) => {
delete config.apps[pluginId];

@ -11,6 +11,7 @@ import {
PromRuleGroupDTO,
PromRuleType,
RulerAlertingRuleDTO,
RulerCloudRuleDTO,
RulerGrafanaRuleDTO,
RulerRecordingRuleDTO,
RulerRuleGroupDTO,
@ -18,34 +19,34 @@ import {
import { setupDataSources } from '../../testSetup/datasources';
import { DataSourceType } from '../../utils/datasource';
import { namespaces } from '../mimirRulerApi';
const prometheusRuleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
name: `test-rule-${sequence}`,
query: 'test-query',
state: PromAlertingRuleState.Inactive,
type: PromRuleType.Alerting,
health: 'ok',
labels: { team: 'infra' },
}));
import { MIMIR_DATASOURCE_UID, PROMETHEUS_DATASOURCE_UID } from './constants';
const rulerAlertingRuleFactory = Factory.define<RulerAlertingRuleDTO>(({ sequence }) => ({
alert: `ruler-alerting-rule-${sequence}`,
expr: 'vector(0)',
annotations: { 'annotation-key-1': 'annotation-value-1' },
labels: { 'label-key-1': 'label-value-1' },
for: '5m',
}));
interface PromRuleFactoryTransientParams {
namePrefix?: string;
}
const rulerRecordingRuleFactory = Factory.define<RulerRecordingRuleDTO>(({ sequence }) => ({
record: `ruler-recording-rule-${sequence}`,
expr: 'vector(0)',
labels: { 'label-key-1': 'label-value-1' },
}));
class PromRuleFactory extends Factory<PromAlertingRuleDTO, PromRuleFactoryTransientParams> {
fromRuler(rulerRule: RulerAlertingRuleDTO) {
return this.params({
name: rulerRule.alert,
query: rulerRule.expr,
type: PromRuleType.Alerting,
labels: rulerRule.labels,
annotations: rulerRule.annotations,
});
}
}
const rulerRuleGroupFactory = Factory.define<RulerRuleGroupDTO>(({ sequence }) => ({
name: `ruler-rule-group-${sequence}`,
rules: [],
interval: '1m',
const prometheusRuleFactory = PromRuleFactory.define(({ sequence, transientParams: { namePrefix } }) => ({
name: `${namePrefix ? `${namePrefix}-` : ''}test-rule-${sequence}`,
query: 'test-query',
state: PromAlertingRuleState.Inactive,
type: PromRuleType.Alerting as const,
health: 'ok',
labels: {},
annotations: {},
}));
const prometheusRuleGroupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
@ -61,7 +62,49 @@ const prometheusRuleGroupFactory = Factory.define<PromRuleGroupDTO>(({ sequence
return group;
});
const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence, params, afterBuild }) => {
const rulerAlertingRuleFactory = Factory.define<RulerAlertingRuleDTO>(({ sequence }) => ({
alert: `test-rule-${sequence}`,
expr: 'up = 1',
labels: { severity: 'warning' },
annotations: { summary: 'test alert' },
}));
const rulerRecordingRuleFactory = Factory.define<RulerRecordingRuleDTO>(({ sequence }) => ({
record: `ruler-recording-rule-${sequence}`,
expr: 'vector(0)',
labels: {},
}));
const rulerGroupFactory = Factory.define<RulerRuleGroupDTO<RulerCloudRuleDTO>, { addToNamespace: string }>(
({ sequence, transientParams, afterBuild }) => {
afterBuild((group) => {
if (transientParams.addToNamespace) {
if (!namespaces[transientParams.addToNamespace]) {
namespaces[transientParams.addToNamespace] = [];
}
namespaces[transientParams.addToNamespace].push(group);
}
});
return {
name: `test-group-${sequence}`,
interval: '1m',
rules: rulerAlertingRuleFactory.buildList(3),
};
}
);
class DataSourceFactory extends Factory<DataSourceInstanceSettings> {
vanillaPrometheus() {
return this.params({ uid: PROMETHEUS_DATASOURCE_UID, name: 'Prometheus' });
}
mimir() {
return this.params({ uid: MIMIR_DATASOURCE_UID, name: 'Mimir' });
}
}
const dataSourceFactory = DataSourceFactory.define(({ sequence, params, afterBuild }) => {
afterBuild((dataSource) => {
config.datasources[dataSource.name] = dataSource;
setupDataSources(...Object.values(config.datasources));
@ -73,7 +116,7 @@ const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence
uid,
type: DataSourceType.Prometheus,
name: `Prometheus-${uid}`,
access: 'proxy',
access: 'proxy' as const,
url: `/api/datasources/proxy/uid/${uid}`,
jsonData: {},
meta: {
@ -142,7 +185,7 @@ export const alertingFactory = {
rule: prometheusRuleFactory,
},
ruler: {
group: rulerRuleGroupFactory,
group: rulerGroupFactory,
alertingRule: rulerAlertingRuleFactory,
recordingRule: rulerRecordingRuleFactory,
grafana: {

@ -10,25 +10,14 @@ import {
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from '../../../../../../types/unified-alerting-dto';
import { AlertGroupUpdated } from '../../../api/alertRuleApi';
import {
getHistoryResponse,
grafanaRulerRule,
namespaceByUid,
namespaces,
time_0,
time_plus_30,
} from '../../grafanaRulerApi';
import { GrafanaGroupUpdatedResponse } from '../../../api/alertRuleModel';
import { getHistoryResponse, grafanaRulerRule, rulerTestDb, time_0, time_plus_30 } from '../../grafanaRulerApi';
import { HandlerOptions } from '../configure';
export const rulerRulesHandler = () => {
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
acc[namespaceByUid[namespaceUid].name] = groups;
return acc;
}, {});
return HttpResponse.json<RulerRulesConfigDTO>(response);
});
export const rulerRulesHandler = () => {
return http.get(`/api/ruler/grafana/api/v1/rules`, () =>
HttpResponse.json<RulerRulesConfigDTO>(rulerTestDb.getRulerConfig())
);
};
export const prometheusRulesHandler = () => {
@ -40,14 +29,12 @@ export const prometheusRulesHandler = () => {
export const getRulerRuleNamespaceHandler = () =>
http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => {
// This mimic API response as closely as possible - Invalid folderUid returns 403
const namespace = namespaces[folderUid];
const namespace = rulerTestDb.getNamespace(folderUid);
if (!namespace) {
return new HttpResponse(null, { status: 403 });
}
return HttpResponse.json<RulerRulesConfigDTO>({
[namespaceByUid[folderUid].name]: namespaces[folderUid],
});
return HttpResponse.json<RulerRulesConfigDTO>(namespace);
});
export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
@ -65,12 +52,12 @@ export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
// This mimic API response as closely as possible.
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
const namespace = namespaces[folderUid];
const namespace = rulerTestDb.getNamespace(folderUid);
if (!namespace) {
return new HttpResponse(null, { status: 403 });
}
return HttpResponse.json<AlertGroupUpdated>({
return HttpResponse.json<GrafanaGroupUpdatedResponse>({
message: 'updated',
updated: [],
});
@ -86,12 +73,13 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
// This mimic API response as closely as possible.
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
const namespace = namespaces[folderUid];
// This should be fixed soon to return 404 instead of 202
const namespace = rulerTestDb.getNamespace(folderUid);
if (!namespace) {
return new HttpResponse(null, { status: 403 });
}
const matchingGroup = namespace.find((group) => group.name === groupName);
const matchingGroup = rulerTestDb.getGroup(folderUid, groupName);
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
@ -109,7 +97,7 @@ export const deleteRulerRuleGroupHandler = (options?: HandlerOptions) =>
return options.response;
}
const namespace = namespaces[folderUid];
const namespace = rulerTestDb.getNamespace(folderUid);
if (!namespace) {
return new HttpResponse(null, { status: 403 });
}

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { PluginExtensionPoints } from '@grafana/data';
import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { CombinedRule, Rule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { PromRuleType } from 'app/types/unified-alerting-dto';
@ -21,14 +21,21 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifierV2) {
export function useRulePluginLinkExtension(rule: Rule | undefined, groupIdentifier: RuleGroupIdentifierV2) {
// This ref provides a stable reference to an empty array, which is used to avoid re-renders when the rule is undefined.
const emptyResponse = useRef<PluginExtensionLink[]>([]);
const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier);
const { links } = usePluginLinks(ruleExtensionPoint);
if (!rule) {
return emptyResponse.current;
}
const ruleOrigin = getRulePluginOrigin(rule);
const ruleType = rule.type;
if (!ruleOrigin || !ruleType) {
return [];
return emptyResponse.current;
}
const { pluginId } = ruleOrigin;
@ -57,8 +64,12 @@ interface EmptyExtensionPoint {
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
function useRuleExtensionPoint(rule: Rule | undefined, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
return useMemo<RuleExtensionPoint>(() => {
if (!rule) {
return { extensionPointId: '' };
}
const ruleType = rule.type;
const { namespace, groupName } = groupIdentifier;
const namespaceIdentifier = 'uid' in namespace ? namespace.uid : namespace.name;

@ -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 } : {});
}

@ -1,16 +1,16 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { memo, useMemo } from 'react';
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting';
import { DataSourceRuleGroupIdentifier, Rule } from 'app/types/unified-alerting';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { equal, fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id';
import { getRulePluginOrigin, prometheusRuleType } from '../utils/rules';
import { createRelativeUrl } from '../utils/url';
import { isCloudRulerGroup } from '../utils/rules';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { DataSourceRuleListItem } from './DataSourceRuleListItem';
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
import { getMatchingRulerRule } from './ruleMatching';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
@ -26,25 +26,12 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
}: DataSourceRuleLoaderProps) {
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);
const href = createViewLinkFromIdentifier(ruleIdentifier);
const originMeta = getRulePluginOrigin(rule);
const { data: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
// @TODO work with context API to propagate rulerConfig and such
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
const {
isLoading,
data: rulerRuleGroup,
// error,
} = useGetRuleGroupForNamespaceQuery(
{
namespace: namespace.name,
group: groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
},
{ skip: !dataSourceInfo?.rulerConfig }
const { isLoading, data: rulerRuleGroup } = useGetRuleGroupForNamespaceQuery(
dsFeatures?.rulerConfig
? { namespace: namespace.name, group: groupName, rulerConfig: dsFeatures?.rulerConfig }
: skipToken
);
const rulerRule = useMemo(() => {
@ -52,10 +39,12 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
return;
}
return rulerRuleGroup.rules.find((rule) =>
equal(fromRulerRule(rulesSource.name, namespace.name, groupName, rule), ruleIdentifier)
);
}, [rulesSource, namespace, groupName, ruleIdentifier, rulerRuleGroup]);
if (!isCloudRulerGroup(rulerRuleGroup)) {
return;
}
return getMatchingRulerRule(rulerRuleGroup, rule);
}, [rulerRuleGroup, rule]);
// 1. get the rule from the ruler API with "ruleWithLocation"
// 1.1 skip this if this datasource does not have a ruler
@ -74,53 +63,13 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
return null;
}, [groupIdentifier, isLoading, rule, rulerRule]);
if (prometheusRuleType.alertingRule(rule)) {
return (
<AlertRuleListItem
name={rule.name}
rulesSource={rulesSource}
application={dataSourceInfo?.application}
group={groupName}
namespace={namespace.name}
href={href}
summary={rule.annotations?.summary}
state={rule.state}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
instancesCount={rule.alerts?.length}
actions={actions}
origin={originMeta}
/>
);
}
if (prometheusRuleType.recordingRule(rule)) {
return (
<RecordingRuleListItem
name={rule.name}
rulesSource={rulesSource}
application={dataSourceInfo?.application}
group={groupName}
namespace={namespace.name}
href={href}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
actions={actions}
origin={originMeta}
/>
);
}
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
return (
<DataSourceRuleListItem
rule={rule}
rulerRule={rulerRule}
groupIdentifier={groupIdentifier}
application={dsFeatures?.application}
actions={actions}
/>
);
});
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(identifier.ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}

@ -19,9 +19,15 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
setupMswServer();
const mimirGroups = alertingFactory.prometheus.group.buildList(5000, { file: 'test-mimir-namespace' });
const mimirGroups = alertingFactory.prometheus.group.buildList(5000, {
file: 'test-mimir-namespace',
rules: alertingFactory.prometheus.rule.buildList(3, undefined, { transient: { namePrefix: 'mimir' } }),
});
alertingFactory.prometheus.group.rewindSequence();
const prometheusGroups = alertingFactory.prometheus.group.buildList(200, { file: 'test-prometheus-namespace' });
const prometheusGroups = alertingFactory.prometheus.group.buildList(200, {
file: 'test-prometheus-namespace',
rules: alertingFactory.prometheus.rule.buildList(3, undefined, { transient: { namePrefix: 'prometheus' } }),
});
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
@ -34,7 +40,9 @@ beforeEach(() => {
const io = mockIntersectionObserver();
describe('RuleList - FilterView', () => {
jest.setTimeout(60 * 1000);
jest.retryTimes(2);
it('should render multiple pages of results', async () => {
render(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
@ -48,39 +56,40 @@ describe('RuleList - FilterView', () => {
it('should filter results by group and rule name ', async () => {
render(
<FilterView
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-8' })}
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-2' })}
/>
);
await loadMoreResults();
const matchingRule = (await screen.findAllByRole('treeitem')).at(0);
expect(matchingRule).toBeInTheDocument();
const matchingRule = await screen.findByRole('treeitem', {
name: /mimir-test-rule-2/,
});
expect(matchingRule).toHaveTextContent('test-rule-8');
expect(matchingRule).toHaveTextContent('mimir-test-rule-2');
expect(matchingRule).toHaveTextContent('test-mimir-namespace');
expect(matchingRule).toHaveTextContent('test-group-4501');
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
});
it('should display rules from multiple datasources', async () => {
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-5' })} />);
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-2' })} />);
await loadMoreResults();
// Mimir has 11 matching rules, 181, 1810, 1811 ... 1819
const matchingMimirRules = await screen.findAllByRole('treeitem', {
name: /test-rule-5 Mimir test-mimir-namespace test-group-181/,
name: /mimir-test-rule-2/,
});
const matchingPrometheusRule = await screen.findByRole('treeitem', {
name: /test-rule-5 Prometheus test-prometheus-namespace test-group-181/,
name: /prometheus-test-rule-2/,
});
expect(matchingMimirRules).toHaveLength(11);
expect(matchingPrometheusRule).toBeInTheDocument();
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
});
}, 90000);
it('should display empty state when no rules are found', async () => {
render(<FilterView filterState={getFilter({ groupName: 'non-existing-group' })} />);
@ -95,7 +104,7 @@ async function loadMoreResults() {
act(() => {
io.enterNode(screen.getByTestId('load-more-helper'));
});
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 8000 });
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 80000 });
}
function getFilter(overrides: Partial<RulesFilter> = {}): RulesFilter {

@ -3,7 +3,7 @@ import { catchError, take, tap, withAbort } from 'ix/asynciterable/operators';
import { useEffect, useRef, useState, useTransition } from 'react';
import { Card, EmptyState, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Trans, t } from 'app/core/internationalization';
import { isLoading, useAsync } from '../hooks/useAsync';
import { RulesFilter } from '../search/rulesSearchParser';
@ -13,7 +13,7 @@ import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemLoader } from './components/AlertRuleListItemLoader';
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
@ -133,13 +133,20 @@ function FilterViewResults({ filterState }: FilterViewProps) {
case 'datasource':
return <DataSourceRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />;
default:
return <UnknownRuleListItem key={key} rule={rule} groupIdentifier={groupIdentifier} />;
return (
<UnknownRuleListItem
key={key}
ruleName={t('alerting.rule-list.unknown-rule-type', 'Unknown rule type')}
groupIdentifier={groupIdentifier}
ruleDefinition={rule}
/>
);
}
})}
{loading && (
<>
<AlertRuleListItemLoader />
<AlertRuleListItemLoader />
<AlertRuleListItemSkeleton />
<AlertRuleListItemSkeleton />
</>
)}
</ul>

@ -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;
}

@ -1,24 +1,35 @@
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { GrafanaPromRuleDTO, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { GrafanaRulesSource } from '../utils/datasource';
import { rulerRuleType } from '../utils/rules';
import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemLoader, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
import {
AlertRuleListItem,
RecordingRuleListItem,
RuleListItemCommonProps,
UnknownRuleListItem,
} from './components/AlertRuleListItem';
import { AlertRuleListItemSkeleton, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { RuleOperation } from './components/RuleListIcon';
const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
interface GrafanaRuleLoaderProps {
rule: GrafanaPromRuleDTO;
groupIdentifier: GrafanaRuleGroupIdentifier;
namespaceName: string;
}
export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) {
const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery(groupIdentifier);
const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery({
folderUid: groupIdentifier.namespace.uid,
groupName: groupIdentifier.groupName,
});
const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === rule.uid);
@ -27,23 +38,48 @@ export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: Graf
return <RulerRuleLoadingError rule={rule} />;
}
return <AlertRuleListItemLoader />;
return <AlertRuleListItemSkeleton />;
}
return (
<GrafanaRuleListItem
rule={rule}
rulerRule={rulerRule}
groupIdentifier={groupIdentifier}
namespaceName={namespaceName}
/>
);
}
interface GrafanaRuleListItemProps {
rule?: GrafanaPromRuleDTO;
rulerRule: RulerGrafanaRuleDTO;
groupIdentifier: GrafanaRuleGroupIdentifier;
namespaceName: string;
operation?: RuleOperation;
}
export function GrafanaRuleListItem({
rule,
rulerRule,
groupIdentifier,
namespaceName,
operation,
}: GrafanaRuleListItemProps) {
const {
grafana_alert: { title, provenance, is_paused },
grafana_alert: { uid, title, provenance, is_paused },
annotations = {},
labels = {},
} = rulerRule;
const commonProps = {
const commonProps: RuleListItemCommonProps = {
name: title,
rulesSource: GrafanaRulesSource,
group: groupIdentifier.groupName,
namespace: namespaceName,
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
health: rule.health,
error: rule.lastError,
href: createRelativeUrl(`/alerting/grafana/${uid}/view`),
health: rule?.health,
error: rule?.lastError,
labels: labels,
isProvisioned: Boolean(provenance),
isPaused: is_paused,
@ -51,20 +87,23 @@ export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: Graf
actions: <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />,
};
if (rule.type === PromRuleType.Alerting) {
if (rulerRuleType.grafana.alertingRule(rulerRule)) {
const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
return (
<AlertRuleListItem
{...commonProps}
summary={annotations.summary}
state={rule.state}
instancesCount={rule.alerts?.length}
state={promAlertingRule?.state}
instancesCount={promAlertingRule?.alerts?.length}
operation={operation}
/>
);
}
if (rule.type === PromRuleType.Recording) {
if (rulerRuleType.grafana.recordingRule(rulerRule)) {
return <RecordingRuleListItem {...commonProps} />;
}
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
return <UnknownRuleListItem ruleName={title} groupIdentifier={groupIdentifier} ruleDefinition={rulerRule} />;
}

@ -4,9 +4,9 @@ import { useEffect, useMemo, useRef } from 'react';
import { Icon, Stack, Text } from '@grafana/ui';
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
import { hashRule } from '../utils/rule-id';
import { groups } from '../utils/navigation';
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
import { DataSourceSection, DataSourceSectionProps } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
@ -20,9 +20,10 @@ const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
}
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
const { uid, name } = rulesSourceIdentifier;
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true });
const groupsGenerator = useRef(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE));
@ -85,24 +86,27 @@ interface RuleGroupListItemProps {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
namespaceName: string;
}
function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: RuleGroupListItemProps) {
const rulesWithGroupId = useMemo(() => {
return group.rules.map((rule) => {
const groupIdentifier: DataSourceRuleGroupIdentifier = {
rulesSource: rulesSourceIdentifier,
namespace: { name: namespaceName },
groupName: group.name,
groupOrigin: 'datasource',
};
return { rule, groupIdentifier };
});
}, [group, namespaceName, rulesSourceIdentifier]);
const groupIdentifier: DataSourceRuleGroupIdentifier = useMemo(
() => ({
rulesSource: rulesSourceIdentifier,
namespace: { name: namespaceName },
groupName: group.name,
groupOrigin: 'datasource',
}),
[rulesSourceIdentifier, namespaceName, group.name]
);
return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
<DataSourceRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
))}
<ListGroup
key={group.name}
name={group.name}
href={groups.detailsPageLink(rulesSourceIdentifier.uid, namespaceName, group.name)}
isOpen={false}
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
>
<DataSourceGroupLoader groupIdentifier={groupIdentifier} expectedRulesCount={group.rules.length} />
</ListGroup>
);
}

@ -5,7 +5,10 @@ import { Icon, Stack, Text } from '@grafana/ui';
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { groups } from '../utils/navigation';
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
import { DataSourceSection } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
@ -17,7 +20,7 @@ import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGrou
const GRAFANA_GROUP_PAGE_SIZE = 40;
export function PaginatedGrafanaLoader() {
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true });
const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE));
@ -82,27 +85,28 @@ interface GrafanaRuleGroupListItemProps {
group: GrafanaPromRuleGroupDTO;
namespaceName: string;
}
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
const groupIdentifier: GrafanaRuleGroupIdentifier = {
groupName: group.name,
namespace: {
uid: group.folderUid,
},
groupOrigin: 'grafana',
};
const groupIdentifier: GrafanaRuleGroupIdentifier = useMemo(
() => ({
groupName: group.name,
namespace: {
uid: group.folderUid,
},
groupOrigin: 'grafana',
}),
[group.name, group.folderUid]
);
return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{group.rules.map((rule) => {
return (
<GrafanaRuleLoader
key={rule.uid}
rule={rule}
namespaceName={namespaceName}
groupIdentifier={groupIdentifier}
/>
);
})}
<ListGroup
key={group.name}
name={group.name}
href={groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name)}
isOpen={false}
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
>
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={namespaceName} />
</ListGroup>
);
}

@ -1,5 +1,5 @@
import { render } from 'test/test-utils';
import { byTestId } from 'testing-library-selector';
import { byRole, byTestId } from 'testing-library-selector';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types';
@ -8,7 +8,7 @@ import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { alertingFactory } from '../mocks/server/db';
import RuleList from './RuleList.v2';
import RuleList, { RuleListActions } from './RuleList.v2';
// This tests only checks if proper components are rendered, so we mock them
// Both FilterView and GroupedView are tested in their own tests
@ -68,3 +68,95 @@ describe('RuleList v2', () => {
expect(ui.groupedView.query()).not.toBeInTheDocument();
});
});
describe('RuleListActions', () => {
const ui = {
newRuleButton: byRole('link', { name: /new alert rule/i }),
moreButton: byRole('button', { name: /more/i }),
moreMenu: byRole('menu'),
menuOptions: {
draftNewRule: byRole('link', { name: /draft a new rule/i }),
newGrafanaRecordingRule: byRole('link', { name: /new grafana recording rule/i }),
newDataSourceRecordingRule: byRole('link', { name: /new data source recording rule/i }),
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
{ permissions: [AccessControlAction.AlertingRuleCreate] },
{ permissions: [AccessControlAction.AlertingRuleExternalWrite] },
{ permissions: [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite] },
])('should show "New alert rule" button when the user has $permissions permissions', ({ permissions }) => {
grantUserPermissions(permissions);
render(<RuleListActions />);
expect(ui.newRuleButton.get()).toBeInTheDocument();
expect(ui.moreButton.get()).toBeInTheDocument();
});
it('should not show "New alert rule" button when user has no permissions to create rules', () => {
grantUserPermissions([]);
render(<RuleListActions />);
expect(ui.newRuleButton.query()).not.toBeInTheDocument();
expect(ui.moreButton.get()).toBeInTheDocument();
});
it('should only show Draft a new rule when the user has view Grafana rules permission', async () => {
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
const { user } = render(<RuleListActions />);
await user.click(ui.moreButton.get());
const menu = await ui.moreMenu.find();
expect(ui.newRuleButton.query()).not.toBeInTheDocument();
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).not.toBeInTheDocument();
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument();
});
it('should show "New Grafana recording rule" option when user has Grafana rule permissions', async () => {
grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
const { user } = render(<RuleListActions />);
await user.click(ui.moreButton.get());
const menu = await ui.moreMenu.find();
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument();
});
it('should show "New Data source recording rule" option when user has external rule permissions', async () => {
grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]);
const { user } = render(<RuleListActions />);
await user.click(ui.moreButton.get());
const menu = await ui.moreMenu.find();
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).not.toBeInTheDocument();
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
});
it('should show both recording rule options when user has all permissions', async () => {
grantUserPermissions([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]);
const { user } = render(<RuleListActions />);
await user.click(ui.moreButton.get());
const menu = await ui.moreMenu.find();
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).toBeInTheDocument();
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
});
});

@ -1,6 +1,12 @@
import { useMemo } from 'react';
import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter';
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useRulesFilter } from '../hooks/useFilteredRules';
import { useURLSearchParams } from '../hooks/useURLSearchParams';
@ -22,9 +28,65 @@ function RuleList() {
);
}
export function RuleListActions() {
const [createGrafanaRuleSupported, createGrafanaRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed;
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
const canCreateRules = canCreateGrafanaRules || canCreateCloudRules;
const moreActionsMenu = useMemo(
() => (
<Menu>
<Menu.Group>
<Menu.Item
label={t('alerting.rule-list.draft-new-rule', 'Draft a new rule')}
icon="file-export"
url="/alerting/export-new-rule"
/>
</Menu.Group>
<Menu.Group label={t('alerting.rule-list.recording-rules', 'Recording rules')}>
{canCreateGrafanaRules && (
<Menu.Item
label={t('alerting.rule-list.new-grafana-recording-rule', 'New Grafana recording rule')}
icon="grafana"
url="/alerting/new/grafana-recording"
/>
)}
{canCreateCloudRules && (
<Menu.Item
label={t('alerting.rule-list.new-datasource-recording-rule', 'New Data source recording rule')}
icon="gf-prometheus"
url="/alerting/new/recording"
/>
)}
</Menu.Group>
</Menu>
),
[canCreateGrafanaRules, canCreateCloudRules]
);
return (
<Stack direction="row" gap={1}>
{canCreateRules && (
<LinkButton variant="primary" icon="plus" href="/alerting/new/alerting">
<Trans i18nKey="alerting.rule-list.new-alert-rule">New alert rule</Trans>
</LinkButton>
)}
<Dropdown overlay={moreActionsMenu}>
<Button variant="secondary">
<Trans i18nKey="alerting.rule-list.more">More</Trans> <Icon name="angle-down" />
</Button>
</Dropdown>
</Stack>
);
}
export default function RuleListPage() {
return (
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={<RuleListActions />}>
<RuleList />
</AlertingPageWrapper>
);

@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import pluralize from 'pluralize';
import { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect, useId } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { Labels, PromAlertingRuleState, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
import { MetaText } from '../../components/MetaText';
@ -20,10 +20,10 @@ import { RulePluginOrigin } from '../../utils/rules';
import { ListItem } from './ListItem';
import { DataSourceIcon } from './Namespace';
import { RuleListIcon } from './RuleListIcon';
import { RuleListIcon, RuleOperation } from './RuleListIcon';
import { calculateNextEvaluationEstimate } from './util';
interface AlertRuleListItemProps {
export interface AlertRuleListItemProps {
name: string;
href: string;
summary?: string;
@ -44,6 +44,7 @@ interface AlertRuleListItemProps {
contactPoint?: string;
actions?: ReactNode;
origin?: RulePluginOrigin;
operation?: RuleOperation;
}
export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
@ -67,8 +68,11 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
labels,
origin,
actions = null,
operation,
} = props;
const listItemAriaId = useId();
const metadata: ReactNode[] = [];
if (namespace && group) {
metadata.push(
@ -124,9 +128,10 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
return (
<ListItem
aria-labelledby={listItemAriaId}
title={
<Stack direction="row" alignItems="center">
<TextLink href={href} inline={false}>
<TextLink href={href} color="primary" inline={false} id={listItemAriaId}>
{name}
</TextLink>
{origin && <PluginOriginBadge pluginId={origin.pluginId} size="sm" />}
@ -137,14 +142,17 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
</Stack>
}
description={<Summary content={summary} error={error} />}
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} />}
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} operation={operation} />}
actions={actions}
meta={metadata}
/>
);
};
type RecordingRuleListItemProps = Omit<AlertRuleListItemProps, 'summary' | 'state' | 'instancesCount' | 'contactPoint'>;
export type RecordingRuleListItemProps = Omit<
AlertRuleListItemProps,
'summary' | 'state' | 'instancesCount' | 'contactPoint'
>;
export function RecordingRuleListItem({
name,
@ -191,6 +199,48 @@ export function RecordingRuleListItem({
);
}
interface RuleOperationListItemProps {
name: string;
namespace: string;
group: string;
rulesSource?: RulesSourceIdentifier;
application?: RulesSourceApplication;
operation: RuleOperation;
}
export function RuleOperationListItem({
name,
namespace,
group,
rulesSource,
application,
operation,
}: RuleOperationListItemProps) {
const listItemAriaId = useId();
const metadata: ReactNode[] = [];
if (namespace && group) {
metadata.push(
<Text color="secondary" variant="bodySmall">
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
</Text>
);
}
return (
<ListItem
aria-labelledby={listItemAriaId}
title={
<Stack direction="row" alignItems="center">
<Text id={listItemAriaId}>{name}</Text>
</Stack>
}
icon={<RuleListIcon operation={operation} />}
meta={metadata}
/>
);
}
interface SummaryProps {
content?: string;
error?: string;
@ -255,23 +305,24 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu
}
interface UnknownRuleListItemProps {
rule: Rule;
ruleName: string;
groupIdentifier: RuleGroupIdentifierV2;
ruleDefinition: Rule | RulerRuleDTO;
}
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
export const UnknownRuleListItem = ({ ruleName, groupIdentifier, ruleDefinition }: UnknownRuleListItemProps) => {
const styles = useStyles2(getStyles);
useEffect(() => {
const { namespace, groupName } = groupIdentifier;
const ruleContext = {
name: rule.name,
name: ruleName,
groupName,
namespace: JSON.stringify(namespace),
rulesSource: getGroupOriginName(groupIdentifier),
};
logError(new Error('unknown rule type'), ruleContext);
}, [rule, groupIdentifier]);
}, [ruleName, groupIdentifier]);
return (
<Alert title={'Unknown rule type'} className={styles.resetMargin}>
@ -280,7 +331,7 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
<Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans>
</summary>
<pre>
<code>{JSON.stringify(rule, null, 2)}</code>
<code>{JSON.stringify(ruleDefinition, null, 2)}</code>
</pre>
</details>
</Alert>
@ -332,3 +383,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: 0,
}),
});
export type RuleListItemCommonProps = Pick<
AlertRuleListItemProps,
Extract<keyof AlertRuleListItemProps, keyof RecordingRuleListItemProps>
>;

@ -7,7 +7,7 @@ import { ListItem } from './ListItem';
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
import { RuleListIcon } from './RuleListIcon';
export function AlertRuleListItemLoader() {
export function AlertRuleListItemSkeleton() {
return (
<ListItem
title={<Skeleton width={64} />}

@ -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)',
},
},
});

@ -3,7 +3,7 @@ import { PropsWithChildren, ReactNode } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { IconButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { Spacer } from '../../components/Spacer';
@ -14,6 +14,7 @@ interface GroupProps extends PropsWithChildren {
metaRight?: ReactNode;
actions?: ReactNode;
isOpen?: boolean;
href?: string;
}
export const ListGroup = ({
@ -22,6 +23,7 @@ export const ListGroup = ({
isOpen = true,
metaRight = null,
actions = null,
href,
children,
}: GroupProps) => {
const styles = useStyles2(getStyles);
@ -36,6 +38,7 @@ export const ListGroup = ({
name={name}
metaRight={metaRight}
actions={actions}
href={href}
/>
{open && <div role="group">{children}</div>}
</div>
@ -47,7 +50,7 @@ type GroupHeaderProps = GroupProps & {
};
const GroupHeader = (props: GroupHeaderProps) => {
const { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props;
const { name, description, metaRight = null, actions = null, isOpen = false, onToggle, href } = props;
const styles = useStyles2(getStyles);
@ -60,9 +63,15 @@ const GroupHeader = (props: GroupHeaderProps) => {
onClick={onToggle}
aria-label={t('common.collapse', 'Collapse')}
/>
<Text truncate variant="body" element="h4">
{name}
</Text>
{href ? (
<TextLink href={href} color="primary" inline={false}>
{name}
</TextLink>
) : (
<Text truncate variant="body" element="h4">
{name}
</Text>
)}
</Stack>
{description}

@ -1,11 +1,11 @@
import { css } from '@emotion/css';
import React, { ReactNode } from 'react';
import React, { AriaAttributes, ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, Text, useStyles2 } from '@grafana/ui';
interface ListItemProps {
interface ListItemProps extends AriaAttributes {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
@ -17,10 +17,16 @@ interface ListItemProps {
export const ListItem = (props: ListItemProps) => {
const styles = useStyles2(getStyles);
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId } = props;
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId, ...ariaAttributes } = props;
return (
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false" data-testid={testId}>
<li
className={styles.alertListItemContainer}
role="treeitem"
aria-selected="false"
data-testid={testId}
{...ariaAttributes}
>
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
{/* icon */}
{icon}

@ -16,7 +16,7 @@ import { createRelativeUrl } from '../../utils/url';
interface Props {
rule: RulerRuleDTO;
promRule: Rule;
promRule?: Rule;
groupIdentifier: RuleGroupIdentifierV2;
/**
* Should we show the buttons in a "compact" state?

@ -1,16 +1,120 @@
import { Dropdown, IconButton, Menu } from '@grafana/ui';
import { skipToken } from '@reduxjs/toolkit/query';
import { isFetchError } from '@grafana/runtime';
import { Dropdown, Icon, IconButton, LinkButton, Menu } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { DataSourceRuleGroupIdentifier, GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { useFolder } from '../../hooks/useFolder';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSourceByUID } from '../../utils/datasource';
import { groups } from '../../utils/navigation';
import { isFederatedRuleGroup, isPluginProvidedGroup, isProvisionedRuleGroup } from '../../utils/rules';
import { GroupStatus } from './GroupStatus';
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
const { useGetGrafanaRulerGroupQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
interface DataSourceGroupsActionMenuProps {
groupIdentifier: DataSourceRuleGroupIdentifier;
}
interface GrafanaGroupsActionMenuProps {
groupIdentifier: GrafanaRuleGroupIdentifier;
}
type RuleGroupActionsMenuProps = DataSourceGroupsActionMenuProps | GrafanaGroupsActionMenuProps;
export function RuleGroupActionsMenu({ groupIdentifier }: RuleGroupActionsMenuProps) {
switch (groupIdentifier.groupOrigin) {
case 'grafana':
return <GrafanaGroupsActionMenu groupIdentifier={groupIdentifier} />;
case 'datasource':
return <DataSourceGroupsActionMenu groupIdentifier={groupIdentifier} />;
default:
return null;
}
}
function DataSourceGroupsActionMenu({ groupIdentifier }: DataSourceGroupsActionMenuProps) {
const { canEditRules } = useRulesAccess();
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: groupIdentifier.rulesSource.uid });
const {
data: rulerRuleGroup,
error: rulerGroupError,
isLoading: isRulerGroupLoading,
} = useGetRuleGroupForNamespaceQuery(
dataSourceInfo?.rulerConfig
? {
namespace: groupIdentifier.namespace.name,
group: groupIdentifier.groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
}
: skipToken
);
const isFederated = rulerRuleGroup ? isFederatedRuleGroup(rulerRuleGroup) : false;
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
const canEdit = !isFederated && !isPluginProvided && canEditRules(groupIdentifier.rulesSource.name);
const rulesSource = getRulesDataSourceByUID(groupIdentifier.rulesSource.uid);
if (!rulesSource) {
// This should never happen
return null;
}
// We don't provide any actions if the data source doesn't support ruler
if (!dataSourceInfo?.rulerConfig) {
return null;
}
if (isRulerGroupLoading) {
return <RuleActionsSkeleton />;
}
if (rulerGroupError) {
if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) {
return <GroupStatus status="deleting" />;
}
return (
<Icon
name="exclamation-triangle"
title={t('alerting.group-actions-menu.group-load-failed', 'Failed to load group details')}
/>
);
}
// This should never happen. Loading and error states are handled above
if (!rulerRuleGroup) {
return <Icon name="exclamation-triangle" title={t('alerting.group-actions-menu.unknown-error', 'Unknown error')} />;
}
export function RuleGroupActionsMenu() {
return (
<Dropdown
placement="right-start"
overlay={
<Menu>
<Menu.Item label={t('alerting.group-actions.edit', 'Edit')} icon="pen" data-testid="edit-group-action" />
<Menu.Item label={t('alerting.group-actions.reorder', 'Re-order rules')} icon="flip" />
<Menu.Divider />
<Menu.Item label={t('alerting.group-actions.export', 'Export')} icon="download-alt" />
<Menu.Item label={t('alerting.group-actions.delete', 'Delete')} icon="trash-alt" destructive />
<Menu.Item
label={t('alerting.group-actions.details', 'Details')}
icon="info-circle"
data-testid="details-group-action"
url={groups.detailsPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
/>
{canEdit && (
<Menu.Item
label={t('alerting.group-actions.edit', 'Edit')}
icon="pen"
data-testid="edit-group-action"
url={groups.editPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
/>
)}
</Menu>
}
>
@ -18,3 +122,34 @@ export function RuleGroupActionsMenu() {
</Dropdown>
);
}
function GrafanaGroupsActionMenu({ groupIdentifier }: GrafanaGroupsActionMenuProps) {
const { canEditRules } = useRulesAccess();
const { data: rulerRuleGroup } = useGetGrafanaRulerGroupQuery({
folderUid: groupIdentifier.namespace.uid,
groupName: groupIdentifier.groupName,
});
const isProvisioned = rulerRuleGroup ? isProvisionedRuleGroup(rulerRuleGroup) : false;
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
const folderUid = groupIdentifier.namespace.uid;
const { folder } = useFolder(folderUid);
const canEdit = folder?.canSave && !isProvisioned && !isPluginProvided && canEditRules(GRAFANA_RULES_SOURCE_NAME);
if (!canEdit) {
return null;
}
return (
<LinkButton
icon="pen"
variant="secondary"
size="sm"
href={groups.editPageLink(GRAFANA_RULES_SOURCE_NAME, folderUid, groupIdentifier.groupName)}
>
{t('alerting.group-actions.edit', 'Edit')}
</LinkButton>
);
}

@ -1,7 +1,9 @@
import { css, keyframes } from '@emotion/css';
import { ComponentProps, memo } from 'react';
import type { RequireAtLeastOne } from 'type-fest';
import { Icon, type IconName, Text, Tooltip } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, type IconName, Text, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import type { RuleHealth } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
@ -14,6 +16,12 @@ interface RuleListIconProps {
state?: PromAlertingRuleState;
health?: RuleHealth;
isPaused?: boolean;
operation?: RuleOperation;
}
export enum RuleOperation {
Creating = 'Creating',
Deleting = 'Deleting',
}
const icons: Record<PromAlertingRuleState, IconName> = {
@ -34,6 +42,14 @@ const stateNames: Record<PromAlertingRuleState, string> = {
[PromAlertingRuleState.Firing]: 'Firing',
};
const operationIcons: Record<RuleOperation, IconName> = {
[RuleOperation.Creating]: 'plus-circle',
[RuleOperation.Deleting]: 'minus-circle',
};
// ⚠ not trivial to update this, you have to re-do the math for the loading spinner
const ICON_SIZE = 18;
/**
* Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view
* This component is often rendered tens or hundreds of times in a single page, so it's performance is important
@ -43,7 +59,11 @@ export const RuleListIcon = memo(function RuleListIcon({
health,
recording = false,
isPaused = false,
operation,
}: RequireAtLeastOne<RuleListIconProps>) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
let iconName: IconName = state ? icons[state] : 'circle';
let iconColor: TextProps['color'] = state ? color[state] : 'secondary';
let stateName: string = state ? stateNames[state] : 'unknown';
@ -72,13 +92,79 @@ export const RuleListIcon = memo(function RuleListIcon({
stateName = 'Paused';
}
if (operation) {
iconName = operationIcons[operation];
iconColor = 'secondary';
stateName = operation;
}
return (
<Tooltip content={stateName} placement="right">
<div>
<Text color={iconColor}>
<Icon name={iconName} size="lg" />
<div className={styles.iconsContainer}>
<Icon name={iconName} width={18} height={18} title={stateName} />
{/* this loading spinner works by using an optical illusion;
the actual icon is static and the "spinning" part is just a semi-transparent darker circle overlayed on top.
This makes it look like there is a small bright colored spinner rotating.
*/}
{operation && (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={styles.spinning}
>
<circle
r={ICON_SIZE / 2}
cx="12"
cy="12"
// make sure to match this color to the color of the list item background where it's being used! Works for both light and dark themes.
stroke={theme.colors.background.primary}
strokeWidth="3"
strokeLinecap="round"
fill="transparent"
strokeOpacity={0.85}
strokeDasharray="24px"
/>
</svg>
)}
</div>
</Text>
</div>
</Tooltip>
);
});
const spin = keyframes({
'0%': {
transform: 'rotate(0deg)',
},
'50%': {
transform: 'rotate(180deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
});
const getStyles = (theme: GrafanaTheme2) => ({
iconsContainer: css({
position: 'relative',
width: 18,
height: 18,
'> *': {
position: 'absolute',
},
}),
spinning: css({
[theme.transitions.handleMotion('no-preference')]: {
animationName: spin,
animationIterationCount: 'infinite',
animationDuration: '1s',
animationTimingFunction: 'linear',
},
}),
});

@ -1,41 +1,97 @@
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import { TypedLazyQueryTrigger } from '@reduxjs/toolkit/query/react';
import { useCallback } from 'react';
import { useDispatch } from 'app/types/store';
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
import { BaseQueryFnArgs } from '../../api/alertingApi';
import { alertRuleApi } from '../../api/alertRuleApi';
import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
interface UseGeneratorHookOptions {
populateCache?: boolean;
}
interface FetchGroupsOptions {
groupLimit?: number;
groupNextToken?: string;
}
export function usePrometheusGroupsGenerator() {
export function usePrometheusGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
const dispatch = useDispatch();
const [getGroups] = useLazyGetGroupsQuery();
return useCallback(
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
const getRuleSourceGroups = (options: FetchGroupsOptions) =>
getGroups({ ruleSource: { uid: ruleSource.uid }, ...options });
yield* genericGroupsGenerator(getRuleSourceGroups, groupLimit);
const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => {
const response = await getGroups({
ruleSource: { uid: ruleSource.uid },
notificationOptions: { showErrorAlert: false },
...fetchOptions,
}).unwrap();
if (hookOptions.populateCache) {
response.data.groups.forEach((group) => {
dispatch(
prometheusApi.util.upsertQueryData(
'getGroups',
{ ruleSource: { uid: ruleSource.uid }, namespace: group.file, groupName: group.name },
{ data: { groups: [group] }, status: 'success' }
)
);
});
}
return response;
};
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit);
},
[getGroups]
[getGroups, dispatch, hookOptions.populateCache]
);
}
export function useGrafanaGroupsGenerator() {
export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
const dispatch = useDispatch();
const [getGrafanaGroups] = useLazyGetGrafanaGroupsQuery();
const getGroupsAndProvideCache = useCallback(
async (fetchOptions: FetchGroupsOptions) => {
const response = await getGrafanaGroups(fetchOptions).unwrap();
// This is not mandatory to preload ruler rules, but it improves the UX
// Because the user waits a bit longer for the initial load but doesn't need to wait for each group to be loaded
if (hookOptions.populateCache) {
const cacheAndRulerPreload = response.data.groups.map(async (group) => {
dispatch(
alertRuleApi.util.prefetch(
'getGrafanaRulerGroup',
{ folderUid: group.folderUid, groupName: group.name },
{ force: true }
)
);
await dispatch(
prometheusApi.util.upsertQueryData(
'getGrafanaGroups',
{ folderUid: group.folderUid, groupName: group.name },
{ data: { groups: [group] }, status: 'success' }
)
);
});
await Promise.allSettled(cacheAndRulerPreload);
}
return response;
},
[getGrafanaGroups, dispatch, hookOptions.populateCache]
);
return useCallback(
async function* (groupLimit: number) {
yield* genericGroupsGenerator(getGrafanaGroups, groupLimit);
yield* genericGroupsGenerator(getGroupsAndProvideCache, groupLimit);
},
[getGrafanaGroups]
[getGroupsAndProvideCache]
);
}
@ -44,38 +100,18 @@ export function useGrafanaGroupsGenerator() {
// For unpaginated data sources we fetch everything in one go
// For paginated we fetch the next page when needed
async function* genericGroupsGenerator<TGroup>(
fetchGroups: TypedLazyQueryTrigger<PromRulesResponse<TGroup>, FetchGroupsOptions, BaseQueryFn<BaseQueryFnArgs>>,
fetchGroups: (options: FetchGroupsOptions) => Promise<PromRulesResponse<TGroup>>,
groupLimit: number
) {
const response = await fetchGroups({ groupLimit });
let response = await fetchGroups({ groupLimit });
yield* response.data.groups;
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
let lastToken: string | undefined = response.data?.groupNextToken;
while (lastToken) {
const response = await fetchGroups({
groupNextToken: lastToken,
groupLimit: groupLimit,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit });
lastToken = response.data?.data?.groupNextToken;
yield* response.data.groups;
lastToken = response.data?.groupNextToken;
}
}

@ -1,6 +1,6 @@
import { AsyncIterableX, empty, from } from 'ix/asynciterable';
import { merge } from 'ix/asynciterable/merge';
import { filter, flatMap, map } from 'ix/asynciterable/operators';
import { catchError, filter, flatMap, map } from 'ix/asynciterable/operators';
import { compact } from 'lodash';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
@ -65,12 +65,16 @@ export function useFilteredRulesIteratorProvider() {
filter((group) => groupFilter(group, normalizedFilterState)),
flatMap((group) => group.rules.map((rule) => [group, rule] as const)),
filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)),
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule))
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)),
catchError(() => empty())
);
const sourceIterables = ruleSourcesToFetchFrom.map((ds) => {
const generator = prometheusGroupsGenerator(ds, groupLimit);
return from(generator).pipe(map((group) => [ds, group] as const));
return from(generator).pipe(
map((group) => [ds, group] as const),
catchError(() => empty())
);
});
// if we have no prometheus data sources, use an empty async iterable

@ -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) };
}

@ -13,7 +13,9 @@ import {
DataSourceRulesSourceIdentifier as DataSourceRulesSourceIdentifier,
GrafanaRulesSourceIdentifier,
GrafanaRulesSourceSymbol,
RuleIdentifier,
RulesSource,
RulesSourceIdentifier,
RulesSourceUid,
} from 'app/types/unified-alerting';
@ -25,6 +27,7 @@ import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext';
import { instancesPermissions, notificationsPermissions, silencesPermissions } from './access-control';
import { getAllDataSources } from './config';
import { isGrafanaRuleIdentifier } from './rules';
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
@ -341,3 +344,15 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti
export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) {
return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true
}
export function ruleIdentifierToRuleSourceIdentifier(ruleIdentifier: RuleIdentifier): RulesSourceIdentifier {
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
return { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' };
}
return {
uid: getDatasourceAPIUid(ruleIdentifier.ruleSourceName),
name: ruleIdentifier.ruleSourceName,
ruleSourceType: 'datasource',
};
}

@ -1,4 +1,10 @@
import { CombinedRule, RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import {
CloudRuleIdentifier,
CombinedRule,
PrometheusRuleIdentifier,
RuleGroupIdentifier,
RuleGroupIdentifierV2,
} from 'app/types/unified-alerting';
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource';
import { rulerRuleType } from './rules';
@ -37,6 +43,20 @@ export function ruleGroupIdentifierV2toV1(groupIdentifier: RuleGroupIdentifierV2
};
}
function fromRuleIdentifier(ruleIdentifier: PrometheusRuleIdentifier | CloudRuleIdentifier): RuleGroupIdentifierV2 {
return {
rulesSource: {
ruleSourceType: 'datasource',
name: ruleIdentifier.ruleSourceName,
uid: getDatasourceAPIUid(ruleIdentifier.ruleSourceName),
},
namespace: { name: ruleIdentifier.namespace },
groupName: ruleIdentifier.groupName,
groupOrigin: 'datasource',
};
}
export const groupIdentifier = {
fromCombinedRule,
fromRuleIdentifier,
};

@ -33,16 +33,15 @@ import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
import { getRulesSourceName } from './datasource';
import { SupportedErrors, getErrorMessageFromCode, isApiMachineryError } from './k8s/errors';
import { getMatcherQueryParams } from './matchers';
import { rulesNav } from './navigation';
import * as ruleId from './rule-id';
import { createAbsoluteUrl, createRelativeUrl } from './url';
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo?: string): string {
const sourceName = getRulesSourceName(ruleSource);
const identifier = ruleId.fromCombinedRule(sourceName, rule);
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(sourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
return rulesNav.detailsPageLink(sourceName, identifier, returnTo ? { returnTo } : undefined);
}
export function createViewLinkV2(
@ -52,10 +51,8 @@ export function createViewLinkV2(
): string {
const ruleSourceName = groupIdentifier.rulesSource.name;
const identifier = ruleId.fromRule(ruleSourceName, groupIdentifier.namespace.name, groupIdentifier.groupName, rule);
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
return rulesNav.detailsPageLink(ruleSourceName, identifier, returnTo ? { returnTo } : undefined);
}
export function createExploreLink(datasource: DataSourceRef, query: string) {

@ -1,9 +1,12 @@
import { RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { createReturnTo } from '../hooks/useReturnTo';
import { stringifyIdentifier } from './rule-id';
import { createRelativeUrl } from './url';
type QueryParams = ConstructorParameters<typeof URLSearchParams>[0];
export const createListFilterLink = (values: Array<[string, string]>) => {
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]);
return createRelativeUrl(`/alerting/list`, params);
@ -43,3 +46,14 @@ export const groups = {
);
},
};
export const rulesNav = {
/**
* Creates a link to the details page of a rule. Encodes the rules source name and rule identifier.
*/
detailsPageLink: (rulesSourceName: string, ruleIdentifier: RuleIdentifier, params?: QueryParams) =>
createRelativeUrl(
`/alerting/${encodeURIComponent(rulesSourceName)}/${encodeURIComponent(stringifyIdentifier(ruleIdentifier))}/view`,
params
),
};

@ -262,16 +262,17 @@ export function hashRulerRule(rule: RulerRuleDTO): string {
return rule.grafana_alert.uid;
}
const fingerprint = getRulerRuleFingerprint(rule);
return hash(JSON.stringify(fingerprint)).toString();
}
function getRulerRuleFingerprint(rule: RulerCloudRuleDTO) {
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
// If the prometheusRulesPrimary feature toggle is enabled, we don't need to hash the query
// We need to make fingerprint compatibility between Prometheus and Ruler rules
// Query often differs between the two, so we can't use it to generate a fingerprint
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.expr);
const includeQuery = !prometheusRulesPrimary;
const fingerprint = getRulerRuleFingerprint(rule, includeQuery);
return hash(JSON.stringify(fingerprint)).toString();
}
export function getRulerRuleFingerprint(rule: RulerCloudRuleDTO, includeQuery: boolean) {
const queryHash = includeQuery ? hashQuery(rule.expr) : '';
const labelsHash = hashLabelsOrAnnotations(rule.labels);
if (rulerRuleType.dataSource.recordingRule(rule)) {
@ -284,14 +285,15 @@ function getRulerRuleFingerprint(rule: RulerCloudRuleDTO) {
}
export function hashRule(rule: Rule): string {
const fingerprint = getPromRuleFingerprint(rule);
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const includeQuery = !prometheusRulesPrimary;
const fingerprint = getPromRuleFingerprint(rule, includeQuery);
return hash(JSON.stringify(fingerprint)).toString();
}
function getPromRuleFingerprint(rule: Rule) {
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.query);
export function getPromRuleFingerprint(rule: Rule, includeQuery: boolean) {
const queryHash = includeQuery ? hashQuery(rule.query) : '';
const labelsHash = hashLabelsOrAnnotations(rule.labels);
if (prometheusRuleType.recordingRule(rule)) {

@ -81,6 +81,11 @@ function isCloudRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerC
function isCloudRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordingRuleDTO {
return typeof rule === 'object' && 'record' in rule;
}
export function isCloudRulerGroup(
rulerRuleGroup: RulerRuleGroupDTO
): rulerRuleGroup is RulerRuleGroupDTO<RulerCloudRuleDTO> {
return rulerRuleGroup.rules.every((r) => isCloudRulerRule(r));
}
/* Prometheus rules */
@ -224,6 +229,10 @@ function isPluginInstalled(pluginId: string) {
return Boolean(config.apps[pluginId]);
}
export function isPluginProvidedGroup(group: RulerRuleGroupDTO): boolean {
return group.rules.some((rule) => isPluginProvidedRule(rule));
}
export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean {
return Boolean(getRulePluginOrigin(rule));
}

@ -503,6 +503,10 @@
"recording": "Recording",
"rule-name": "Rule name"
},
"ds-group-loader": {
"group-deleting": "The group is being deleted",
"group-load-failed": "Failed to load rules from group {{ groupName }} in {{ namespaceName }}"
},
"export": {
"subtitle": {
"formats": "Select the format and download the file or copy the contents to clipboard",
@ -521,10 +525,12 @@
},
"group-actions": {
"actions-trigger": "Rule group actions",
"delete": "Delete",
"edit": "Edit",
"export": "Export",
"reorder": "Re-order rules"
"details": "Details",
"edit": "Edit"
},
"group-actions-menu": {
"group-load-failed": "Failed to load group details",
"unknown-error": "Unknown error"
},
"group-details": {
"ds-features-error": "Error loading data source details",
@ -565,6 +571,9 @@
"rule-group-error": "Error loading rule group",
"title": "Edit evaluation group"
},
"group-loader": {
"group-load-failed": "Failed to load rules from group {{ groupName }} in {{ namespaceName }}"
},
"irm-integration": {
"connection-method": "How to connect to IRM",
"disabled-description": "Enable Grafana IRM to use this integration",
@ -820,6 +829,7 @@
},
"rule-list": {
"configure-datasource": "Configure",
"draft-new-rule": "Draft a new rule",
"ds-error-boundary": {
"description": "Check the data source configuration. Does the data source support Prometheus API?",
"title": "Unable to load rules from this data source"
@ -828,15 +838,20 @@
"no-more-results": "No more results – showing {{numberOfRules}} rules",
"no-rules-found": "No alert or recording rules matched your current set of filters."
},
"more": "More",
"new-alert-rule": "New alert rule",
"new-datasource-recording-rule": "New Data source recording rule",
"new-grafana-recording-rule": "New Grafana recording rule",
"pagination": {
"next-page": "next page",
"previous-page": "previous page"
},
"recording-rules": "Recording rules",
"return-button": {
"title": "Alert rules"
},
"rulerrule-loading-error": "Failed to load the rule"
"rulerrule-loading-error": "Failed to load the rule",
"unknown-rule-type": "Unknown rule type"
},
"rule-state": {
"creating": "Creating",
@ -855,7 +870,7 @@
"rule-viewer": {
"error-loading": "Something went wrong loading the rule",
"prometheus-consistency-check": {
"alert-message": "Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view.",
"alert-message": "Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list view.",
"alert-title": "Update in progress"
}
},

Loading…
Cancel
Save