diff --git a/.betterer.results b/.betterer.results index 05c8396c389..6bf5f614817 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2073,19 +2073,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], - "public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "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 or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], @@ -2128,9 +2115,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], - "public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with 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 or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 36bdbb34a7b..9dcdf128257 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -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, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 4eefa3b8b67..1606ceb4ae0 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -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, 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, { 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; diff --git a/public/app/features/alerting/unified/api/alertRuleModel.ts b/public/app/features/alerting/unified/api/alertRuleModel.ts new file mode 100644 index 00000000000..bd0e2a6469a --- /dev/null +++ b/public/app/features/alerting/unified/api/alertRuleModel.ts @@ -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; +} diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index fd512216b81..24641f6c4e6 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -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 { @@ -15,7 +15,7 @@ export interface PromRulesResponse { 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 & { +type GrafanaPromRulesOptions = Omit & { + folderUid?: string; dashboardUid?: string; panelId?: number; }; @@ -33,20 +34,33 @@ type GrafanaPromRulesOptions = Omit & { export const prometheusApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ getGroups: build.query>, 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>) => { @@ -54,12 +68,12 @@ export const prometheusApi = alertingApi.injectEndpoints({ }, }), getGrafanaGroups: build.query, 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, diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index ea84662c638..4570bbfc7e0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index f68296b6fbb..5231f59ec63 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -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; diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index afb6fe0c30c..cdbe614a591 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -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; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 6a25509e67c..125222e57d0 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -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 = () => { } > - {prometheusRulesPrimary && } + {shouldUseConsistencyCheck && } {/* tabs and tab content */} @@ -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(); - const { isConsistent, error } = usePrometheusCreationConsistencyCheck(ruleIdentifier); +const PrometheusConsistencyCheck = withErrorBoundary( + ({ ruleIdentifier }: PrometheusConsistencyCheckProps) => { + const [ref, { width }] = useMeasure(); - if (isConsistent) { - return null; - } + const { hasRuler } = useHasRulerV2(ruleIdentifierToRuleSourceIdentifier(ruleIdentifier)); + const { result: ruleLocation } = useRuleLocation(ruleIdentifier); - if (error) { - return ( - - {stringifyErrorLike(error)} - - ); - } + const { waitForGroupConsistency, groupConsistent } = useRuleGroupConsistencyCheck(); - return ( - - - - - Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view. - - - - ); -} + const [waitAction, waitState] = useAsync((groupIdentifier: RuleGroupIdentifierV2) => { + return waitForGroupConsistency(groupIdentifier); + }); + + useEffect(() => { + if (ruleLocation && hasRuler) { + waitAction.execute(ruleLocation.groupIdentifier); + } + }, [ruleLocation, hasRuler, waitAction]); + + if (isError(waitState)) { + return ( + + {stringifyErrorLike(waitState.error)} + + ); + } + + // 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 ( + + + + + Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list + view. + + + + ); + } + + return null; + }, + { errorLogger: logError } +); export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err'; diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx deleted file mode 100644 index f755d6ff9e3..00000000000 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx +++ /dev/null @@ -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/), // 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( - - ); - - 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(); - - 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(); - 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( - - ); - - 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(); - }); -}); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx deleted file mode 100644 index ab1a5640ad8..00000000000 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ /dev/null @@ -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 ; - } else { - return ; - } -} - -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; -type AlertsWithForTableProps = DynamicTableItemProps; - -export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => { - const styles = useStyles2(getStyles); - - const { watch } = useFormContext(); - 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 ; - } else { - if (!isValidEvaluation(currentInterval)) { - return ; - } - if (numberEvaluations === 0) { - return ( - - ); - } else { - return <>{numberEvaluations}; - } - } - }, - size: 0.4, - }, - ]; - }, [currentInterval, unknownCurrentInterval]); - - return ( -
- -
- ); -}; - -interface FormValues { - namespaceName: string; - groupName: string; - groupInterval: string; -} - -export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterOptions => ({ - 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 ( - - {isLoading && } - {error ? stringifyErrorLike(error) : null} - {ruleGroup && } - - ); -} - -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({ - 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 ( - -
- {!props.hideFolder && ( - - - {nameSpaceLabel} - - } - invalid={Boolean(errors.namespaceName) ? true : undefined} - error={errors.namespaceName?.message} - > - - - {isGrafanaManagedGroup && props.folderUrl && ( - - )} - - )} - - Evaluation group - - } - invalid={!!errors.groupName} - error={errors.groupName?.message} - > - - - - Evaluation interval - - } - invalid={Boolean(errors.groupInterval) ? true : undefined} - error={errors.groupInterval?.message} - > - - - setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })} - /> - - - - {/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */} - {isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( - - )} - - {!hasSomeNoRecordingRules &&
This group does not contain alert rules.
} - {hasSomeNoRecordingRules && ( - <> -
List of rules that belong to this group
-
- #Eval column represents the number of evaluations needed before alert starts firing. -
- - - )} - {error && {stringifyErrorLike(error)}} -
- - - - -
- -
- ); -} - -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, - }), -}); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx deleted file mode 100644 index 779c68197e4..00000000000 --- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx +++ /dev/null @@ -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>([]); - - 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([]); - - 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 ( - } - onDismiss={onClose} - onClickBackdrop={onClose} - > - {loadingRules && 'Loading...'} - {rulesWithUID.length > 0 && ( - <> - - ( - - )} - > - {(droppableProvided: DroppableProvided) => ( -
- {rulesWithUID.map((rule, index) => ( - - {(provided: DraggableProvided) => } - - ))} - {droppableProvided.placeholder} -
- )} -
-
- - - - - - )} -
- ); -}; - -interface ListItemProps extends React.HTMLAttributes { - 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 ( -
-
- {getRuleName(rule)} - {rulerRuleType.any.recordingRule(rule) && ( - <> - {' '} - - - )} -
- {rulerRuleType.dataSource.alertingRule(rule) &&
{rule.alert}
} - -
- ); -}; - -interface ModalHeaderProps { - namespace: CombinedRuleNamespace; - group: CombinedRuleGroup; -} - -const ModalHeader = ({ namespace, group }: ModalHeaderProps) => { - const styles = useStyles2(getStyles); - - return ( -
- - {isCloudRulesSource(namespace.rulesSource) && ( - - {namespace.rulesSource.meta.name} - - )} - {namespace.name} - - {group.name} -
- ); -}; - -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), - }), -}); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 8ff803ac421..705274a7046 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -93,7 +93,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource ); } - if (!rule.promRule) { + if (!rule.promRule && !rule.rulerRule) { return null; } diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx index d049b2a0ac2..2d770e93fff 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -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', diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 659f4ec703b..0d6409d3099 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -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; diff --git a/public/app/features/alerting/unified/group-details/GroupEditPage.tsx b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx index 29c6afd8b63..3854bedcbbb 100644 --- a/public/app/features/alerting/unified/group-details/GroupEditPage.tsx +++ b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx @@ -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; diff --git a/public/app/features/alerting/unified/group-details/validation.ts b/public/app/features/alerting/unified/group-details/validation.ts new file mode 100644 index 00000000000..8d635855196 --- /dev/null +++ b/public/app/features/alerting/unified/group-details/validation.ts @@ -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 = (rules: RulerRuleDTO[]): RegisterOptions => ({ + 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'; + } + }, +}); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index b6727029fbe..4bb06a6c962 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -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 { @@ -189,15 +197,20 @@ export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState { 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[0] = { namespace, @@ -151,8 +153,9 @@ export function useRuleGroupIsInSync() { export function useRuleGroupConsistencyCheck() { const { isGroupInSync } = useRuleGroupIsInSync(); + const [groupConsistent, setGroupConsistent] = useState(); - const consistencyInterval = useRef(); + const consistencyInterval = useRef | 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((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() { diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 3de7de4e7d9..880fa6a2f05 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -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 = (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) => { diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 4e0cd678109..a5658f18123 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -43,6 +43,7 @@ import { AlertQuery, GrafanaAlertState, GrafanaAlertStateDecision, + GrafanaPromAlertingRuleDTO, GrafanaRuleDefinition, PromAlertingRuleState, PromRuleType, @@ -223,6 +224,17 @@ export const mockPromAlertingRule = (partial: Partial = {}): Alert }; }; +export const mockGrafanaPromAlertingRule = ( + partial: Partial = {} +): GrafanaPromAlertingRuleDTO => { + return { + ...mockPromAlertingRule(), + uid: 'mock-rule-uid-123', + folderUid: 'NAMESPACE_UID', + ...partial, + }; +}; + export const mockGrafanaRulerRule = (partial: Partial = {}): RulerGrafanaRuleDTO => { return { for: '', diff --git a/public/app/features/alerting/unified/mocks/grafanaRulerApi.ts b/public/app/features/alerting/unified/mocks/grafanaRulerApi.ts index a2fc3a99764..13eb4185823 100644 --- a/public/app/features/alerting/unified/mocks/grafanaRulerApi.ts +++ b/public/app/features/alerting/unified/mocks/grafanaRulerApi.ts @@ -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 = { name: grafanaRulerGroupName, interval: '1m', rules: [grafanaRulerRule], }; -export const grafanaRulerGroup2: RulerRuleGroupDTO = { +export const grafanaRulerGroup2: RulerRuleGroupDTO = { name: grafanaRulerGroupName2, interval: '1m', rules: [grafanaRulerRule], }; -export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = { +export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = { name: 'empty-group', interval: '1m', rules: [], }; -export const namespaceByUid: Record = { - [grafanaRulerNamespace.uid]: grafanaRulerNamespace, - [grafanaRulerNamespace2.uid]: grafanaRulerNamespace2, -}; +// AKA Folder +interface GrafanaNamespace { + name: string; + uid: string; +} -export const namespaces: Record = { - [grafanaRulerNamespace.uid]: [grafanaRulerGroup, grafanaRulerGroup2], - [grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup], -}; +export class RulerTestDb { + private namespaces = new Map(); // UID -> Name + private groupsByNamespaceUid = new Map(); + + 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; diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 70e23c4debc..6cd1c875f01 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -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]; diff --git a/public/app/features/alerting/unified/mocks/server/db.ts b/public/app/features/alerting/unified/mocks/server/db.ts index 14dac82f6cb..b7911e522c0 100644 --- a/public/app/features/alerting/unified/mocks/server/db.ts +++ b/public/app/features/alerting/unified/mocks/server/db.ts @@ -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(({ 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(({ 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(({ sequence }) => ({ - record: `ruler-recording-rule-${sequence}`, - expr: 'vector(0)', - labels: { 'label-key-1': 'label-value-1' }, -})); +class PromRuleFactory extends Factory { + 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(({ 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(({ sequence }) => { @@ -61,7 +62,49 @@ const prometheusRuleGroupFactory = Factory.define(({ sequence return group; }); -const dataSourceFactory = Factory.define(({ sequence, params, afterBuild }) => { +const rulerAlertingRuleFactory = Factory.define(({ sequence }) => ({ + alert: `test-rule-${sequence}`, + expr: 'up = 1', + labels: { severity: 'warning' }, + annotations: { summary: 'test alert' }, +})); + +const rulerRecordingRuleFactory = Factory.define(({ sequence }) => ({ + record: `ruler-recording-rule-${sequence}`, + expr: 'vector(0)', + labels: {}, +})); + +const rulerGroupFactory = Factory.define, { 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 { + 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(({ 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: { diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts index bafb6f44d82..6b24f417845 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts @@ -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((acc, [namespaceUid, groups]) => { - acc[namespaceByUid[namespaceUid].name] = groups; - return acc; - }, {}); - return HttpResponse.json(response); - }); +export const rulerRulesHandler = () => { + return http.get(`/api/ruler/grafana/api/v1/rules`, () => + HttpResponse.json(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({ - [namespaceByUid[folderUid].name]: namespaces[folderUid], - }); + return HttpResponse.json(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({ + return HttpResponse.json({ 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({ 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 }); } diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts index ca0750ac538..3c4333450d4 100644 --- a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts +++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts @@ -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([]); + 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(() => { + if (!rule) { + return { extensionPointId: '' }; + } + const ruleType = rule.type; const { namespace, groupName } = groupIdentifier; const namespaceIdentifier = 'uid' in namespace ? namespace.uid : namespace.name; diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx new file mode 100644 index 00000000000..0064e7f953d --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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)); +} diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx new file mode 100644 index 00000000000..f62de0edcc1 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx @@ -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) => ( + + ))} + + ); + } + + const isError = isPromResponseError || isDsFeaturesError || isRulerGroupError; + if (isError) { + if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) { + return ( + + ); + } + + return ( + + ); + } + + // 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 ( + + ); + } + + // Data source without ruler + if (promGroup) { + return ( + <> + {promGroup.rules.map((rule) => ( + + ))} + + ); + } + + // This should never happen + return ( + + ); +} + +interface RulerBasedGroupRulesProps { + groupIdentifier: DataSourceRuleGroupIdentifier; + promGroup: PromRuleGroupDTO; + rulerGroup: RulerRuleGroupDTO; + 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 ? ( + + } + /> + ) : ( + + ); + })} + {promOnlyRules.map((rule) => ( + + ))} + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx new file mode 100644 index 00000000000..f93e63d3d92 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx @@ -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 ( + + ); + case PromRuleType.Recording: + return ; + default: + return ; + } +} + +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 } : {}); +} diff --git a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx index 95edc6db6f6..ac54bc375b3 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx @@ -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 ( - - ); - } - - if (prometheusRuleType.recordingRule(rule)) { - return ( - - ); - } - - return ; + return ( + + ); }); - -function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { - const paramId = encodeURIComponent(stringifyIdentifier(identifier)); - const paramSource = encodeURIComponent(identifier.ruleSourceName); - - return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); -} diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index de51e6dde8b..307954bf189 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -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(); @@ -48,39 +56,40 @@ describe('RuleList - FilterView', () => { it('should filter results by group and rule name ', async () => { render( ); 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(); + render(); 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(); @@ -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 { diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index ea6b3ceec49..78d84c838a3 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -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 ; default: - return ; + return ( + + ); } })} {loading && ( <> - - + + )} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx new file mode 100644 index 00000000000..565c5fb4884 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx @@ -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(); + + 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(); + + 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(); + + 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): GrafanaPromRuleGroupDTO { + return { + folderUid: group.name, + name: group.name, + file: group.name, + rules: group.rules.map((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 | GrafanaPromRuleGroupDTO +): GrafanaRuleGroupIdentifier { + return { + groupName: group.name, + namespace: { uid: grafanaRulerNamespace.uid }, + groupOrigin: 'grafana', + }; +} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx new file mode 100644 index 00000000000..df87f6fcd15 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx @@ -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) => ( + + ))} + + ); + } + + if (!rulerResponse || !promResponse) { + return ( + + ); + } + + return ( + <> + {rulerResponse.rules.map((rulerRule) => { + const promRule = matches.get(rulerRule); + + if (!promRule) { + return ( + + ); + } + + return ( + + ); + })} + {promOnlyRules.map((rule) => ( + + ))} + + ); +} + +interface MatchingResult { + matches: Map; + /** + * 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 { + const promRulesMap = new Map(promRules.map((rule) => [rule.uid, rule])); + + const matchingResult = rulerRules.reduce( + (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; +} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx index f4d127feaa5..7444e303fb8 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx @@ -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 ; } - return ; + return ; } + return ( + + ); +} + +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: , }; - if (rule.type === PromRuleType.Alerting) { + if (rulerRuleType.grafana.alertingRule(rulerRule)) { + const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined; + return ( ); } - if (rule.type === PromRuleType.Recording) { + if (rulerRuleType.grafana.recordingRule(rulerRule)) { return ; } - return ; + return ; } diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index 04e5892bfe3..27db7d85ca4 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -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> { 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 ( - }> - {rulesWithGroupId.map(({ rule, groupIdentifier }) => ( - - ))} + } + > + ); } diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index f9daf26d6b6..59ecd1f31ac 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -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 ( - }> - {group.rules.map((rule) => { - return ( - - ); - })} + } + > + ); } diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 527c50687d2..ae5e0e479e4 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 86949386e3a..3d79cb1e783 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -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( + () => ( + + + + + + {canCreateGrafanaRules && ( + + )} + {canCreateCloudRules && ( + + )} + + + ), + [canCreateGrafanaRules, canCreateCloudRules] + ); + + return ( + + {canCreateRules && ( + + New alert rule + + )} + + + + + ); +} + export default function RuleListPage() { return ( - + }> ); diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index 38b6c50d2db..23bc1ad3e18 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -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 ( - + {name} {origin && } @@ -137,14 +142,17 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
} description={} - icon={} + icon={} actions={actions} meta={metadata} /> ); }; -type RecordingRuleListItemProps = Omit; +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( + + + + ); + } + + return ( + + {name} + + } + icon={} + 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 ( @@ -280,7 +331,7 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt Rule definition
-          {JSON.stringify(rule, null, 2)}
+          {JSON.stringify(ruleDefinition, null, 2)}
         
@@ -332,3 +383,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ margin: 0, }), }); + +export type RuleListItemCommonProps = Pick< + AlertRuleListItemProps, + Extract +>; diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx index ad0db11991d..c74bd088ca6 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx @@ -7,7 +7,7 @@ import { ListItem } from './ListItem'; import { RuleActionsSkeleton } from './RuleActionsSkeleton'; import { RuleListIcon } from './RuleListIcon'; -export function AlertRuleListItemLoader() { +export function AlertRuleListItemSkeleton() { return ( } diff --git a/public/app/features/alerting/unified/rule-list/components/GroupStatus.tsx b/public/app/features/alerting/unified/rule-list/components/GroupStatus.tsx new file mode 100644 index 00000000000..fb5af7d3b4f --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/GroupStatus.tsx @@ -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 ( +
+
+ {status === 'deleting' && ( + +
+ +
+
+ )} +
+ ); +} + +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)', + }, + }, +}); diff --git a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx index 253b69cdb90..0b2f0277789 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx @@ -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 &&
{children}
}
@@ -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')} /> - - {name} - + {href ? ( + + {name} + + ) : ( + + {name} + + )} {description} diff --git a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx index 3a67d08696b..85c3a0063ef 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx @@ -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 ( -
  • +
  • {/* icon */} {icon} diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx index 21199ce1523..ed5c62ddd9e 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -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? diff --git a/public/app/features/alerting/unified/rule-list/components/RuleGroupActionsMenu.tsx b/public/app/features/alerting/unified/rule-list/components/RuleGroupActionsMenu.tsx index 5b61f83d543..9a4f0de227b 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleGroupActionsMenu.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleGroupActionsMenu.tsx @@ -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 ; + case 'datasource': + return ; + 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 ; + } + + if (rulerGroupError) { + if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) { + return ; + } + + return ( + + ); + } + + // This should never happen. Loading and error states are handled above + if (!rulerRuleGroup) { + return ; + } -export function RuleGroupActionsMenu() { return ( - - - - - + + {canEdit && ( + + )} } > @@ -18,3 +122,34 @@ export function RuleGroupActionsMenu() { ); } + +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 ( + + {t('alerting.group-actions.edit', 'Edit')} + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx b/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx index 29b714770d8..7316f43cd0f 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx @@ -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 = { @@ -34,6 +42,14 @@ const stateNames: Record = { [PromAlertingRuleState.Firing]: 'Firing', }; +const operationIcons: Record = { + [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) { + 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 (
    - +
    + + {/* 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 && ( + + + + )} +
    ); }); + +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', + }, + }), +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index 3c1c0a642a3..39339caa91e 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -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( - fetchGroups: TypedLazyQueryTrigger, FetchGroupsOptions, BaseQueryFn>, + fetchGroups: (options: FetchGroupsOptions) => Promise>, 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; } } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 01ec7fe7bcf..8c494c0545a 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -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 diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts new file mode 100644 index 00000000000..827b70e4ffd --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts @@ -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); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.ts new file mode 100644 index 00000000000..513a43be6f8 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/ruleMatching.ts @@ -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, 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, 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; + promOnlyRules: PromRuleDTO[]; +} + +export function matchRulesGroup( + rulerGroup: RulerRuleGroupDTO, + promGroup: PromRuleGroupDTO +): 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(), unmatchedPromRules: new Set(promGroup.rules) } + ); + + return { matches: matchingResult.matches, promOnlyRules: Array.from(matchingResult.unmatchedPromRules) }; +} diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 5262000a258..c81dfd4c2a8 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -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) { 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', + }; +} diff --git a/public/app/features/alerting/unified/utils/groupIdentifier.ts b/public/app/features/alerting/unified/utils/groupIdentifier.ts index aa0701c7767..43ac0699250 100644 --- a/public/app/features/alerting/unified/utils/groupIdentifier.ts +++ b/public/app/features/alerting/unified/utils/groupIdentifier.ts @@ -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, }; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 686194d24c6..987629f185c 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -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) { diff --git a/public/app/features/alerting/unified/utils/navigation.ts b/public/app/features/alerting/unified/utils/navigation.ts index 3d786ef7887..ba103c47639 100644 --- a/public/app/features/alerting/unified/utils/navigation.ts +++ b/public/app/features/alerting/unified/utils/navigation.ts @@ -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[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 + ), +}; diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index cfe1c288543..b2eb9a663c0 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -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)) { diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 82f73cd7312..b6bccd3f989 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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 { + 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)); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 119e67c796f..4c2ac7b8db9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" } },