diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 39ab938fb28..3156876f8cf 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -196,6 +196,7 @@ Experimental features might be changed or removed without prior notice. | `dataplaneAggregator` | Enable grafana dataplane aggregator | | `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | | `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying | +| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources | | `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | | `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | | `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 7104c77c50e..55014a72992 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -204,6 +204,7 @@ export interface FeatureToggles { dataplaneAggregator?: boolean; newFiltersUI?: boolean; lokiSendDashboardPanelNames?: boolean; + alertingPrometheusRulesPrimary?: boolean; singleTopNav?: boolean; exploreLogsShardSplitting?: boolean; exploreLogsAggregatedMetrics?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d42b8ac1ebd..d37138665a3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1403,6 +1403,13 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, }, + { + Name: "alertingPrometheusRulesPrimary", + Description: "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "singleTopNav", Description: "Unifies the top search bar and breadcrumb bar into one", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 1aea6fced26..d00de03a658 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -185,6 +185,7 @@ alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false +alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true singleTopNav,experimental,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2be496880cb..c89153d1748 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -751,6 +751,10 @@ const ( // Send dashboard and panel names to Loki when querying FlagLokiSendDashboardPanelNames = "lokiSendDashboardPanelNames" + // FlagAlertingPrometheusRulesPrimary + // Uses Prometheus rules as the primary source of truth for ruler-enabled data sources + FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary" + // FlagSingleTopNav // Unifies the top search bar and breadcrumb bar into one FlagSingleTopNav = "singleTopNav" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 3cd366933c2..14973b66edb 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -240,6 +240,22 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "alertingPrometheusRulesPrimary", + "resourceVersion": "1727332930692", + "creationTimestamp": "2024-09-09T13:56:47Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-09-26 06:42:10.692959 +0000 UTC" + } + }, + "spec": { + "description": "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } + }, { "metadata": { "name": "alertingQueryAndExpressionsStepMode", diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index 724ce2bed77..c76b8408f2c 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -12,8 +12,8 @@ import { searchFolders } from '../../manage-dashboards/state/actions'; import { discoverFeatures } from './api/buildInfo'; import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { setupMswServer } from './mockApi'; import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import * as config from './utils/config'; import { DataSourceType } from './utils/datasource'; @@ -25,7 +25,12 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ })); jest.mock('./api/buildInfo'); -jest.mock('./api/ruler'); +jest.mock('./api/ruler', () => ({ + rulerUrlBuilder: jest.requireActual('./api/ruler').rulerUrlBuilder, + fetchRulerRules: jest.fn(), + fetchRulerRulesGroup: jest.fn(), + fetchRulerRulesNamespace: jest.fn(), +})); jest.mock('../../../../app/features/manage-dashboards/state/actions'); // there's no angular scope in test and things go terribly wrong when trying to render the query editor row. @@ -116,7 +121,6 @@ const mocks = { fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), fetchRulerRules: jest.mocked(fetchRulerRules), - fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), }, }; @@ -133,6 +137,8 @@ function getDiscoverFeaturesMock(application: PromApplication, features?: Partia }; } +setupMswServer(); + describe('RuleEditor cloud: checking editable data sources', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 5ed14c39266..ef3ec39be48 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -807,8 +807,9 @@ describe('RuleList', () => { renderRuleList(); - await waitFor(() => expect(mocks.api.fetchRules).toHaveBeenCalledTimes(1)); + const groupRows = await ui.ruleGroup.findAll(); + expect(groupRows).toHaveLength(1); expect(ui.exportButton.get()).toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap index ee06f142f10..61ee13594c8 100644 --- a/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap @@ -74,17 +74,6 @@ exports[`RuleEditor cloud can create a new cloud alert 1`] = ` "method": "POST", "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", }, - { - "body": "", - "headers": [ - [ - "accept", - "application/json, text/plain, */*", - ], - ], - "method": "GET", - "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir", - }, { "body": "", "headers": [ diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap index 9e7216c81e0..d95652d615c 100644 --- a/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap @@ -69,17 +69,6 @@ exports[`RuleEditor recording rules can create a new cloud recording rule 1`] = "method": "POST", "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", }, - { - "body": "", - "headers": [ - [ - "accept", - "application/json, text/plain, */*", - ], - ], - "method": "GET", - "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir", - }, { "body": "", "headers": [ diff --git a/public/app/features/alerting/unified/components/DynamicTable.tsx b/public/app/features/alerting/unified/components/DynamicTable.tsx index ae405474a82..f10b5ecc7a4 100644 --- a/public/app/features/alerting/unified/components/DynamicTable.tsx +++ b/public/app/features/alerting/unified/components/DynamicTable.tsx @@ -43,7 +43,6 @@ export interface DynamicTableProps { onCollapse?: (item: DynamicTableItemProps) => void; onExpand?: (item: DynamicTableItemProps) => void; isExpanded?: (item: DynamicTableItemProps) => boolean; - renderExpandedContent?: ( item: DynamicTableItemProps, index: number, diff --git a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx index eb3532f954a..d95fc0a6897 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx @@ -1,15 +1,14 @@ import { css } from '@emotion/css'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Field, useStyles2, VirtualizedSelect } from '@grafana/ui'; -import { useDispatch } from 'app/types'; -import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; -import { fetchRulerRulesAction } from '../../state/actions'; import { RuleFormValues } from '../../types/rule-form'; +import { useAlertRuleSuggestions } from './useAlertRuleSuggestions'; + interface Props { rulesSourceName: string; } @@ -23,27 +22,22 @@ export const GroupAndNamespaceFields = ({ rulesSourceName }: Props) => { } = useFormContext(); const style = useStyles2(getStyle); - - const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - const dispatch = useDispatch(); - useEffect(() => { - dispatch(fetchRulerRulesAction({ rulesSourceName })); - }, [rulesSourceName, dispatch]); - - const rulesConfig = rulerRequests[rulesSourceName]?.result; + const { namespaceGroups, isLoading } = useAlertRuleSuggestions(rulesSourceName); const namespace = watch('namespace'); - const namespaceOptions = useMemo( - (): Array> => - rulesConfig ? Object.keys(rulesConfig).map((namespace) => ({ label: namespace, value: namespace })) : [], - [rulesConfig] + const namespaceOptions: Array> = useMemo( + () => + Array.from(namespaceGroups.keys()).map((namespace) => ({ + label: namespace, + value: namespace, + })), + [namespaceGroups] ); - const groupOptions = useMemo( - (): Array> => - (namespace && rulesConfig?.[namespace]?.map((group) => ({ label: group.name, value: group.name }))) || [], - [namespace, rulesConfig] + const groupOptions: Array> = useMemo( + () => (namespace && namespaceGroups.get(namespace)?.map((group) => ({ label: group, value: group }))) || [], + [namespace, namespaceGroups] ); return ( @@ -66,6 +60,8 @@ export const GroupAndNamespaceFields = ({ rulesSourceName }: Props) => { }} options={namespaceOptions} width={42} + isLoading={isLoading} + disabled={isLoading} /> )} name="namespace" @@ -87,6 +83,8 @@ export const GroupAndNamespaceFields = ({ rulesSourceName }: Props) => { setValue('group', value.value ?? ''); }} className={style.input} + isLoading={isLoading} + disabled={isLoading} /> )} name="group" 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 7c010fcfc5c..d85563db127 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,7 +1,7 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useState } from 'react'; import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; @@ -9,7 +9,6 @@ import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } fro import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; import { getRuleGroupLocationFromFormValues, @@ -20,7 +19,8 @@ import { isGrafanaRulerRulePaused, isRecordingRuleByType, } from 'app/features/alerting/unified/utils/rules'; -import { RuleWithLocation } from 'app/types/unified-alerting'; +import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; +import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { LogMessages, @@ -29,8 +29,10 @@ import { trackAlertRuleFormError, trackAlertRuleFormSaved, } from '../../../Analytics'; +import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles'; import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; +import { useURLSearchParams } from '../../../hooks/useURLSearchParams'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL, @@ -45,6 +47,8 @@ import { normalizeDefaultAnnotations, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id'; +import * as ruleId from '../../../utils/rule-id'; +import { createRelativeUrl } from '../../../utils/url'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -61,10 +65,12 @@ type Props = { prefill?: Partial; // Existing implies we modify existing rule. Prefill only provides default form values }; +const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + export const AlertRuleForm = ({ existing, prefill }: Props) => { const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); - const [queryParams] = useQueryParams(); + const [queryParams] = useURLSearchParams(); const [showEditYaml, setShowEditYaml] = useState(false); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); @@ -77,7 +83,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const uidFromParams = routeParams.id; - const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo); const [showDeleteModal, setShowDeleteModal] = useState(false); const defaultValues: RuleFormValues = useMemo(() => { @@ -89,8 +94,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { return formValuesFromPrefill(prefill); } - if (typeof queryParams.defaults === 'string') { - return formValuesFromQueryParams(queryParams.defaults, ruleType); + if (queryParams.has('defaults')) { + return formValuesFromQueryParams(queryParams.get('defaults') ?? '', ruleType); } return { @@ -160,10 +165,17 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { ); } - if (exitOnSave && returnTo) { + const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier; + if (exitOnSave) { + const returnTo = queryParams.get('returnTo') || getReturnToUrl(ruleGroupIdentifier, ruleDefinition); + locationService.push(returnTo); - } else if (isCloudRulerRule(ruleDefinition)) { - const { dataSourceName, namespaceName, groupName } = getRuleGroupLocationFromFormValues(values); + return; + } + + // Cloud Ruler rules identifier changes on update due to containing rule name and hash components + // After successful update we need to update the URL to avoid displaying 404 errors + if (isCloudRulerRule(ruleDefinition)) { const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition); locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`); } @@ -171,6 +183,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const deleteRule = async () => { if (existing) { + const returnTo = queryParams.get('returnTo') || '/alerting/list'; const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); @@ -193,6 +206,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const cancelRuleCreation = () => { logInfo(LogMessages.cancelSavingAlertRule); trackAlertRuleFormCancelled({ formAction: existing ? 'update' : 'create' }); + locationService.getHistory().goBack(); }; const evaluateEveryInForm = watch('evaluateEvery'); @@ -222,11 +236,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {isSubmitting && } Save rule and exit - - - + {existing ? ( - )} - - - )} - {hasNoAlertRulesCreatedYet && } - {hasAlertRulesCreated && } - - ); - }, - { style: 'page' } -); + const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); + const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + return ( + // We don't want to show the Loading... indicator for the whole page. + // We show separate indicators for Grafana-managed and Cloud rules + }> + + + {hasAlertRulesCreated && ( + + {view === 'groups' && hasActiveFilters && ( + + )} + + + )} + {hasNoAlertRulesCreatedYet && } + {hasAlertRulesCreated && } + + ); +}; -export default RuleList; +export default withErrorBoundary(RuleListV1, { style: 'page' }); export function CreateAlertButton() { const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index 3e2e87a4438..2aca5f867f7 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -5,16 +5,21 @@ import { ConfirmModal } from '@grafana/ui'; import { dispatch } from 'app/store/store'; import { CombinedRule } from 'app/types/unified-alerting'; +import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; -import { fetchPromAndRulerRulesAction } from '../../state/actions'; +import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; +import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions'; import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id'; -import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; +import { getRuleGroupLocationFromCombinedRule, isCloudRuleIdentifier } from '../../utils/rules'; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; +const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { const [ruleToDelete, setRuleToDelete] = useState(); const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); + const { waitForRemoval } = usePrometheusConsistencyCheck(); const dismissModal = useCallback(() => { setRuleToDelete(undefined); @@ -39,13 +44,20 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); + if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) { + await waitForRemoval(ruleIdentifier); + } else { + // Without this the delete popup will close and the user will still see the deleted rule + await dispatch(fetchRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); + } + dismissModal(); if (redirectToListView) { locationService.replace('/alerting/list'); } }, - [deleteRuleFromGroup, dismissModal, redirectToListView] + [deleteRuleFromGroup, dismissModal, redirectToListView, waitForRemoval] ); const modal = useMemo( 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 3f71b0b9e95..4c2efd64e58 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -1,9 +1,11 @@ import { css } from '@emotion/css'; import { chain, isEmpty, truncate } from 'lodash'; import { useState } from 'react'; +import { useMeasure } from 'react-use'; import { NavModelItem, UrlQueryValue } from '@grafana/data'; -import { Alert, LinkButton, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { t, Trans } from '@grafana/ui/src/utils/i18n'; import { PageInfoItem } from 'app/core/components/Page/types'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; @@ -12,11 +14,12 @@ import { AlertInstanceTotalState, CombinedRule, RuleHealth, RuleIdentifier } fro import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { defaultPageNav } from '../../RuleViewer'; +import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; +import { usePrometheusCreationConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { Annotation } from '../../utils/constants'; -import { makeDashboardLink, makePanelLink } from '../../utils/misc'; +import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc'; import { - RulePluginOrigin, getRulePluginOrigin, isAlertingRule, isFederatedRuleGroup, @@ -24,6 +27,7 @@ import { isGrafanaRulerRule, isGrafanaRulerRulePaused, isRecordingRule, + RulePluginOrigin, } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; import { AlertLabels } from '../AlertLabels'; @@ -51,8 +55,10 @@ export enum ActiveTab { Details = 'details', } +const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + const RuleViewer = () => { - const { rule } = useAlertRule(); + const { rule, identifier } = useAlertRule(); const { pageNav, activeTab } = usePageNav(rule); // this will be used to track if we are in the process of cloning a rule @@ -112,6 +118,7 @@ const RuleViewer = () => { } > + {prometheusRulesPrimary && } {/* tabs and tab content */} @@ -261,6 +268,42 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi ); }; +/** + * 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); + + if (isConsistent) { + return null; + } + + if (error) { + return ( + + {stringifyErrorLike(error)} + + ); + } + + return ( + + + + + Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view. + + + + ); +} + export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err'; export function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] { diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index b747de47c3c..3658ce385c9 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -1,6 +1,5 @@ import { css, cx } from '@emotion/css'; import { useState } from 'react'; -import { useLocation } from 'react-router-dom-v5-compat'; import { GrafanaTheme2 } from '@grafana/data'; import { LinkButton, Stack, useStyles2 } from '@grafana/ui'; @@ -42,7 +41,6 @@ interface Props { */ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton, rule, rulesSource }: Props) => { const dispatch = useDispatch(); - const location = useLocation(); const style = useStyles2(getStyles); const redirectToListView = compact ? false : true; @@ -57,8 +55,6 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton const { namespace, group, rulerRule } = rule; const { hasActiveFilters } = useRulesFilter(); - const returnTo = location.pathname + location.search; - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const [editRuleSupported, editRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); @@ -85,7 +81,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton key="view" variant="secondary" icon="eye" - href={createViewLink(rulesSource, rule, returnTo)} + href={createViewLink(rulesSource, rule)} > {!compact && 'View'} @@ -95,9 +91,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton if (rulerRule && canEditRule) { const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); - const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, { - returnTo, - }); + const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`); buttons.push( = { nodata: 0, }; -export const RuleStats = ({ namespaces }: Props) => { - const stats = statsFromNamespaces(namespaces); +// Stats calculation is an expensive operation +// Make sure we repeat that as few times as possible +export const RuleStats = React.memo(({ namespaces }: Props) => { + const deferredNamespaces = useDeferredValue(namespaces); + + const stats = useMemo(() => statsFromNamespaces(deferredNamespaces), [deferredNamespaces]); const total = totalFromStats(stats); const statsComponents = getComponentsFromStats(stats); @@ -49,7 +53,9 @@ export const RuleStats = ({ namespaces }: Props) => { )} ); -}; +}); + +RuleStats.displayName = 'RuleStats'; interface RuleGroupStatsProps { group: CombinedRuleGroup; diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx index 097ecefce1f..a868bc87fbd 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -40,8 +40,8 @@ const mocks = { function mockUseHasRuler(hasRuler: boolean, rulerRulesLoaded: boolean) { mocks.useHasRuler.mockReturnValue({ - hasRuler: () => hasRuler, - rulerRulesLoaded: () => rulerRulesLoaded, + hasRuler, + rulerRulesLoaded, }); } diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 5ae024d814c..3cee6fead7d 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,21 +1,20 @@ import { css } from '@emotion/css'; import pluralize from 'pluralize'; -import { useEffect, useState } from 'react'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; -import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; import { LogMessages, logInfo } from '../../Analytics'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; import { useRulesAccess } from '../../utils/accessControlHooks'; -import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; +import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc'; -import { isFederatedRuleGroup, isGrafanaRulerRule, rulesSourceToDataSourceName } from '../../utils/rules'; +import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; @@ -39,8 +38,8 @@ interface Props { export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { const { rulesSource } = namespace; - const styles = useStyles2(getStyles); const [deleteRuleGroup] = useDeleteRuleGroup(); + const styles = useStyles2(getStyles); const [isEditingGroup, setIsEditingGroup] = useState(false); const [isDeletingGroup, setIsDeletingGroup] = useState(false); @@ -54,14 +53,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: setIsCollapsed(!expandAll); }, [expandAll]); - const { hasRuler, rulerRulesLoaded } = useHasRuler(); + const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource); const rulerRule = group.rules[0]?.rulerRule; const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const { folder } = useFolder(folderUID); // group "is deleting" if rules source has ruler, but this group has no rules that are in ruler - const isDeleting = - hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule); + const isDeleting = hasRuler && rulerRulesLoaded && !group.rules.find((rule) => !!rule.rulerRule); const isFederated = isFederatedRuleGroup(group); // check if group has provisioned items @@ -76,7 +74,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: const deleteGroup = async () => { const namespaceName = decodeGrafanaNamespace(namespace).name; const groupName = group.name; - const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); + const dataSourceName = getRulesSourceName(namespace.rulesSource); const ruleGroupIdentifier: RuleGroupIdentifier = { namespaceName, groupName, dataSourceName }; await deleteRuleGroup.execute(ruleGroupIdentifier); @@ -171,7 +169,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: } } } - } else if (canEditRules(rulesSource.name) && hasRuler(rulesSource)) { + } else if (canEditRules(rulesSource.name) && hasRuler) { if (!isFederated) { actionIcons.push( - - {isCloudRulesSource(rulesSource) && ( - - {rulesSource.meta.name} - - )} + + { // eslint-disable-next-line
setIsCollapsed(!isCollapsed)}> @@ -326,6 +316,33 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: RulesGroup.displayName = 'RulesGroup'; +// It's a simple component but we render 80 of them on the list page it needs to be fast +// The Tooltip component is expensive to render and the rulesSource doesn't change often +// so memoization seems to bring a lot of benefit here +const CloudSourceLogo = React.memo(({ rulesSource }: { rulesSource: RulesSource | string }) => { + const styles = useStyles2(getStyles); + + if (isCloudRulesSource(rulesSource)) { + return ( + + {rulesSource.meta.name} + + ); + } + + return null; +}); + +CloudSourceLogo.displayName = 'CloudSourceLogo'; + +// We render a lot of these on the list page, and the Icon component does quite a bit of work +// to render its contents +const FolderIcon = React.memo(({ isCollapsed }: { isCollapsed: boolean }) => { + return ; +}); + +FolderIcon.displayName = 'FolderIcon'; + export const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css({}), diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index dfadb75dd8b..eb6f2b68ba0 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -1,14 +1,22 @@ import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; +import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, Tooltip } from '@grafana/ui'; +import { useStyles2, Tooltip, Pagination } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; +import { alertRuleApi } from '../../api/alertRuleApi'; +import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; +import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; +import { useAsync } from '../../hooks/useAsync'; +import { attachRulerRuleToCombinedRule } from '../../hooks/useCombinedRuleNamespaces'; import { useHasRuler } from '../../hooks/useHasRuler'; +import { usePagination } from '../../hooks/usePagination'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { Annotation } from '../../utils/constants'; +import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { getRulePluginOrigin, isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; @@ -36,6 +44,11 @@ interface Props { className?: string; } +const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + +const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi; +const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi; + export const RulesTable = ({ rules, className, @@ -46,21 +59,26 @@ export const RulesTable = ({ showNextEvaluationColumn = false, }: Props) => { const styles = useStyles2(getStyles); - const wrapperClass = cx(styles.wrapper, className, { [styles.wrapperMargin]: showGuidelines }); + const { pageItems, page, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); + + const { result: rulesWithRulerDefinitions, status: rulerRulesLoadingStatus } = useLazyLoadRulerRules(pageItems); + + const isLoadingRulerGroup = rulerRulesLoadingStatus === 'loading'; + const items = useMemo((): RuleTableItemProps[] => { - return rules.map((rule, ruleIdx) => { + return rulesWithRulerDefinitions.map((rule, ruleIdx) => { return { id: `${rule.namespace.name}-${rule.group.name}-${rule.name}-${ruleIdx}`, data: rule, }; }); - }, [rules]); + }, [rulesWithRulerDefinitions]); - const columns = useColumns(showSummaryColumn, showGroupColumn, showNextEvaluationColumn); + const columns = useColumns(showSummaryColumn, showGroupColumn, showNextEvaluationColumn, isLoadingRulerGroup); - if (!rules.length) { + if (!pageItems.length) { return
{emptyMessage}
; } @@ -73,13 +91,71 @@ export const RulesTable = ({ isExpandable={true} items={items} renderExpandedContent={({ data: rule }) => } - pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }} - paginationStyles={styles.pagination} + /> +
); }; +/** + * This hook is used to lazy load the Ruler rule for each rule. + * If the `prometheusRulesPrimary` feature flag is enabled, the hook will fetch the Ruler rule counterpart for each Prometheus rule. + * If the `prometheusRulesPrimary` feature flag is disabled, the hook will return the rules as is. + * @param rules Combined rules with or without Ruler rule property + * @returns Combined rules enriched with Ruler rule property + */ +function useLazyLoadRulerRules(rules: CombinedRule[]) { + const [fetchRulerRuleGroup] = useLazyGetRuleGroupForNamespaceQuery(); + const [fetchDsFeatures] = useLazyDiscoverDsFeaturesQuery(); + + const [actions, state] = useAsync(async () => { + const result = Promise.all( + rules.map(async (rule) => { + const dsFeatures = await fetchDsFeatures( + { rulesSourceName: getRulesSourceName(rule.namespace.rulesSource) }, + true + ).unwrap(); + + // Due to lack of ruleUid and folderUid in Prometheus rules we cannot do the lazy load for GMA + if (dsFeatures.rulerConfig && rule.namespace.rulesSource !== GRAFANA_RULES_SOURCE_NAME) { + // RTK Query should handle caching and deduplication for us + const rulerRuleGroup = await fetchRulerRuleGroup( + { + namespace: rule.namespace.name, + group: rule.group.name, + rulerConfig: dsFeatures.rulerConfig, + }, + true + ).unwrap(); + + attachRulerRuleToCombinedRule(rule, rulerRuleGroup); + } + + return rule; + }) + ); + return result; + }, rules); + + useEffect(() => { + if (prometheusRulesPrimary) { + actions.execute(); + } else { + // We need to reset the actions to update the rules if they changed + // Otherwise useAsync acts like a cache and always return the first rules passed to it + actions.reset(); + } + }, [rules, actions]); + + return state; +} + export const getStyles = (theme: GrafanaTheme2) => ({ wrapperMargin: css({ [theme.breakpoints.up('md')]: { @@ -93,6 +169,9 @@ export const getStyles = (theme: GrafanaTheme2) => ({ width: 'auto', borderRadius: theme.shape.radius.default, }), + skeletonWrapper: css({ + flex: 1, + }), pagination: css({ display: 'flex', margin: 0, @@ -102,36 +181,22 @@ export const getStyles = (theme: GrafanaTheme2) => ({ borderLeft: `1px solid ${theme.colors.border.medium}`, borderRight: `1px solid ${theme.colors.border.medium}`, borderBottom: `1px solid ${theme.colors.border.medium}`, + float: 'none', }), }); -function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNextEvaluationColumn: boolean) { - const { hasRuler, rulerRulesLoaded } = useHasRuler(); - +function useColumns( + showSummaryColumn: boolean, + showGroupColumn: boolean, + showNextEvaluationColumn: boolean, + isRulerLoading: boolean +) { return useMemo((): RuleTableColumnProps[] => { - const ruleIsDeleting = (rule: CombinedRule) => { - const { namespace, promRule, rulerRule } = rule; - const { rulesSource } = namespace; - return Boolean(hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && promRule && !rulerRule); - }; - - const ruleIsCreating = (rule: CombinedRule) => { - const { namespace, promRule, rulerRule } = rule; - const { rulesSource } = namespace; - return Boolean(hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && rulerRule && !promRule); - }; - const columns: RuleTableColumnProps[] = [ { id: 'state', label: 'State', - renderCell: ({ data: rule }) => { - const isDeleting = ruleIsDeleting(rule); - const isCreating = ruleIsCreating(rule); - const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); - - return ; - }, + renderCell: ({ data: rule }) => , size: '165px', }, { @@ -232,21 +297,50 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe id: 'actions', label: 'Actions', // eslint-disable-next-line react/display-name - renderCell: ({ data: rule }) => { - const isDeleting = ruleIsDeleting(rule); - const isCreating = ruleIsCreating(rule); - return ( - - ); - }, + renderCell: ({ data: rule }) => , size: '200px', }); return columns; - }, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, hasRuler, rulerRulesLoaded]); + }, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, isRulerLoading]); +} + +function RuleStateCell({ rule }: { rule: CombinedRule }) { + const { isDeleting, isCreating, isPaused } = useRuleStatus(rule); + return ; +} + +function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadingRuler: boolean }) { + const styles = useStyles2(getStyles); + const { isDeleting, isCreating } = useRuleStatus(rule); + + if (isLoadingRuler) { + return ; + } + + return ( + + ); +} + +function useRuleStatus(rule: CombinedRule) { + const { hasRuler, rulerRulesLoaded } = useHasRuler(rule.namespace.rulesSource); + const { promRule, rulerRule } = rule; + + // If prometheusRulesPrimary is enabled, we don't fetch rules from the Ruler API (except for Grafana managed rules) + // so there is no way to detect statuses + if (prometheusRulesPrimary && !isGrafanaRulerRule(rulerRule)) { + return { isDeleting: false, isCreating: false, isPaused: false }; + } + + const isDeleting = Boolean(hasRuler && rulerRulesLoaded && promRule && !rulerRule); + const isCreating = Boolean(hasRuler && rulerRulesLoaded && rulerRule && !promRule); + const isPaused = isGrafanaRulerRule(rulerRule) && isGrafanaRulerRulePaused(rulerRule); + + return { isDeleting, isCreating, isPaused }; } diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts new file mode 100644 index 00000000000..8e7ebc60c0e --- /dev/null +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -0,0 +1,3 @@ +import { config } from '@grafana/runtime'; + +export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts index 41a4664c54a..e67a2558387 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts +++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts @@ -2,13 +2,11 @@ import { produce } from 'immer'; import { isEqual } from 'lodash'; import { t } from 'app/core/internationalization'; -import { dispatch } from 'app/store/store'; import { RuleGroupIdentifier, EditableRuleIdentifier } from 'app/types/unified-alerting'; import { PostableRuleDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { addRuleAction, updateRuleAction } from '../../reducers/ruler/ruleGroups'; -import { fetchRulerRulesAction } from '../../state/actions'; import { isGrafanaRuleIdentifier, isGrafanaRulerRule } from '../../utils/rules'; import { useAsync } from '../useAsync'; @@ -25,7 +23,7 @@ export function useAddRuleToRuleGroup() { const successMessage = t('alerting.rules.add-rule.success', 'Rule added successfully'); return useAsync(async (ruleGroup: RuleGroupIdentifier, rule: PostableRuleDTO, interval?: string) => { - const { namespaceName, dataSourceName } = ruleGroup; + const { namespaceName } = ruleGroup; // the new rule might have to be created in a new group, pass name and interval (optional) to the action const action = addRuleAction({ rule, interval, groupName: ruleGroup.groupName }); @@ -38,9 +36,6 @@ export function useAddRuleToRuleGroup() { notificationOptions: { successMessage }, }).unwrap(); - // @TODO remove - await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName })); - return result; }); } diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 12a10278774..f449bada4e4 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -9,7 +9,7 @@ import { CombinedRule } from 'app/types/unified-alerting'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { useAlertmanager } from '../state/AlertmanagerContext'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { isAdmin } from '../utils/misc'; import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; @@ -150,17 +150,12 @@ export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleActi }, [abilities, actions]); } +// This hook is being called a lot in different places +// In some cases multiple times for ~80 rules (e.g. on the list page) +// We need to investigate further if some of these calls are redundant +// In the meantime, memoizing the result helps export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities { - const rulesSource = rule.namespace.rulesSource; - const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name; - - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - const isFederated = isFederatedRuleGroup(rule.group); - const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule); - const isPluginProvided = isPluginProvidedRule(rule); - - // if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited - const immutableRule = isProvisioned || isFederated || isPluginProvided; + const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource); const { isEditable, @@ -169,27 +164,39 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities = { - [AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create), - [AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read), - [AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false], - [AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false], - [AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore), - [AlertRuleAction.Silence]: canSilence, - [AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed], - [AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false], - }; + const abilities = useMemo>(() => { + const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + const isFederated = isFederatedRuleGroup(rule.group); + const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule); + const isPluginProvided = isPluginProvidedRule(rule); + + // if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited + const immutableRule = isProvisioned || isFederated || isPluginProvided; + + // while we gather info, pretend it's not supported + const MaybeSupported = loading ? NotSupported : isRulerAvailable; + const MaybeSupportedUnlessImmutable = immutableRule ? NotSupported : MaybeSupported; + + // Creating duplicates of plugin-provided rules does not seem to make a lot of sense + const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported; + + const rulesPermissions = getRulesPermissions(rulesSourceName); + + const abilities: Abilities = { + [AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create), + [AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read), + [AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false], + [AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false], + [AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore), + [AlertRuleAction.Silence]: canSilence, + [AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed], + [AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false], + }; + + return abilities; + }, [rule, loading, isRulerAvailable, isEditable, isRemovable, rulesSourceName, exportAllowed, canSilence]); return abilities; } diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index af5e843ad9d..7f0239142fc 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -10,7 +10,7 @@ import { getDataSourceByName } from '../utils/datasource'; import * as ruleId from '../utils/rule-id'; import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; -import { attachRulerRulesToCombinedRules } from './useCombinedRuleNamespaces'; +import { attachRulerRulesToCombinedRules, combineRulesNamespaces } from './useCombinedRuleNamespaces'; export function useCloudCombinedRulesMatching( ruleName: string, @@ -100,7 +100,7 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request } = useRuleLocation(ruleIdentifier); const { - currentData: promRuleNs, + currentData: promRuleNs = [], isLoading: isLoadingPromRules, error: promRuleNsError, } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( @@ -135,30 +135,21 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request }, [dsFeatures, fetchRulerRuleGroup, ruleLocation]); const rule = useMemo(() => { - if (!promRuleNs || !ruleSource) { + if (!ruleSource || !ruleLocation) { return; } - if (promRuleNs.length > 0) { - const namespaces = promRuleNs.map((ns) => - attachRulerRulesToCombinedRules(ruleSource, ns, rulerRuleGroup ? [rulerRuleGroup] : []) - ); + const rulerConfig = rulerRuleGroup ? { [ruleLocation.namespace]: [rulerRuleGroup] } : {}; - for (const namespace of namespaces) { - for (const group of namespace.groups) { - for (const rule of group.rules) { - const id = ruleId.fromCombinedRule(ruleSourceName, rule); + const combinedNamespaces = combineRulesNamespaces(ruleSource, promRuleNs, rulerConfig); + const combinedRules = combinedNamespaces.flatMap((ns) => ns.groups).flatMap((group) => group.rules); - if (ruleId.equal(id, ruleIdentifier)) { - return rule; - } - } - } - } - } + const matchingRule = combinedRules.find((rule) => + ruleId.equal(ruleId.fromCombinedRule(ruleSourceName, rule), ruleIdentifier) + ); - return; - }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource]); + return matchingRule; + }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource, ruleLocation]); return { loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup, @@ -167,13 +158,14 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request }; } -interface RuleLocation { +export interface RuleLocation { + datasource: string; namespace: string; group: string; ruleName: string; } -function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState { +export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState { const { isLoading, currentData, error, isUninitialized } = alertRuleApi.endpoints.getAlertRule.useQuery( { uid: isGrafanaRuleIdentifier(ruleIdentifier) ? ruleIdentifier.uid : '' }, { skip: !isGrafanaRuleIdentifier(ruleIdentifier), refetchOnMountOrArgChange: true } @@ -183,6 +175,7 @@ function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState + rulerRuleToCombinedRule(rulerRule, rule.namespace, rule.group) + ); + const existingRulerRulesByName = combinedRulesFromRuler.reduce((acc, rule) => { + const sameNameRules = acc.get(rule.name); + if (sameNameRules) { + sameNameRules.push(rule); + } else { + acc.set(rule.name, [rule]); + } + return acc; + }, new Map()); + + const matchingRulerRule = getExistingRuleInGroup(rule.promRule, existingRulerRulesByName, rule.namespace.rulesSource); + if (matchingRulerRule) { + rule.rulerRule = matchingRulerRule.rulerRule; + rule.query = matchingRulerRule.query; + rule.labels = matchingRulerRule.labels; + rule.annotations = matchingRulerRule.annotations; + } +} + export function addCombinedPromAndRulerGroups( ns: CombinedRuleNamespace, promGroups: RuleGroup[], diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index cf5e9cc1061..be82f26a3b3 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,7 +1,7 @@ import uFuzzy from '@leeoniya/ufuzzy'; import { produce } from 'immer'; import { chain, compact, isEmpty } from 'lodash'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo } from 'react'; import { getDataSourceSrv } from '@grafana/runtime'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; @@ -105,8 +105,11 @@ export function useRulesFilter() { } export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { + const deferredNamespaces = useDeferredValue(namespaces); + const deferredFilterState = useDeferredValue(filterState); + return useMemo(() => { - const filteredRules = filterRules(namespaces, filterState); + const filteredRules = filterRules(deferredNamespaces, deferredFilterState); // Totals recalculation is a workaround for the lack of server-side filtering filteredRules.forEach((namespace) => { @@ -125,7 +128,7 @@ export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterStat }); return filteredRules; - }, [namespaces, filterState]); + }, [deferredNamespaces, deferredFilterState]); }; export const filterRules = ( diff --git a/public/app/features/alerting/unified/hooks/useHasRuler.ts b/public/app/features/alerting/unified/hooks/useHasRuler.ts index d9451d4bb86..db2e85f09ee 100644 --- a/public/app/features/alerting/unified/hooks/useHasRuler.ts +++ b/public/app/features/alerting/unified/hooks/useHasRuler.ts @@ -1,32 +1,21 @@ -import { useCallback } from 'react'; - import { RulesSource } from 'app/types/unified-alerting'; -import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; +import { getRulesSourceName } from '../utils/datasource'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -// datasource has ruler if it's grafana managed or if we're able to load rules from it -export function useHasRuler() { +const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; + +// datasource has ruler if the discovery api returns a rulerConfig +export function useHasRuler(rulesSource: RulesSource) { const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules); + const rulesSourceName = getRulesSourceName(rulesSource); - const hasRuler = useCallback( - (rulesSource: string | RulesSource) => { - const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name; - return rulesSourceName === GRAFANA_RULES_SOURCE_NAME || !!rulerRules[rulesSourceName]?.result; - }, - [rulerRules] - ); + const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName }); - const rulerRulesLoaded = useCallback( - (rulesSource: RulesSource) => { - const rulesSourceName = getRulesSourceName(rulesSource); - const result = rulerRules[rulesSourceName]?.result; - - return Boolean(result); - }, - [rulerRules] - ); + const hasRuler = Boolean(dsFeatures?.rulerConfig); + const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result); return { hasRuler, rulerRulesLoaded }; } diff --git a/public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts b/public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts new file mode 100644 index 00000000000..9d2ffff3dd3 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { CloudRuleIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; + +import { alertRuleApi } from '../api/alertRuleApi'; +import * as ruleId from '../utils/rule-id'; +import { isCloudRuleIdentifier } from '../utils/rules'; + +import { useAsync } from './useAsync'; + +const { useLazyPrometheusRuleNamespacesQuery } = alertRuleApi; + +const CONSISTENCY_CHECK_POOL_INTERVAL = 3 * 1000; // 3 seconds; +const CONSISTENCY_CHECK_TIMEOUT = 90 * 1000; // 90 seconds + +const { setInterval, clearInterval } = window; + +function useMatchingPromRuleExists() { + const [fetchPrometheusNamespaces] = useLazyPrometheusRuleNamespacesQuery(); + + const matchingPromRuleExists = useCallback( + async (ruleIdentifier: CloudRuleIdentifier) => { + const { ruleSourceName, namespace, groupName, ruleName } = ruleIdentifier; + const namespaces = await fetchPrometheusNamespaces({ + ruleSourceName, + namespace, + groupName, + ruleName, + }).unwrap(); + + const matchingGroup = namespaces.find((ns) => ns.name === namespace)?.groups.find((g) => g.name === groupName); + + const hasMatchingRule = matchingGroup?.rules.some((r) => { + const currentRuleIdentifier = ruleId.fromRule(ruleSourceName, namespace, groupName, r); + return ruleId.equal(currentRuleIdentifier, ruleIdentifier); + }); + + return hasMatchingRule ?? false; + }, + [fetchPrometheusNamespaces] + ); + + return { matchingPromRuleExists }; +} + +export function usePrometheusConsistencyCheck() { + const { matchingPromRuleExists } = useMatchingPromRuleExists(); + + const removalConsistencyInterval = useRef(); + const creationConsistencyInterval = useRef(); + + useEffect(() => { + return () => { + clearRemovalInterval(); + clearCreationInterval(); + }; + }, []); + + const clearRemovalInterval = () => { + if (removalConsistencyInterval.current) { + clearInterval(removalConsistencyInterval.current); + removalConsistencyInterval.current = undefined; + } + }; + + const clearCreationInterval = () => { + if (creationConsistencyInterval.current) { + clearInterval(creationConsistencyInterval.current); + creationConsistencyInterval.current = undefined; + } + }; + + async function waitForRemoval(ruleIdentifier: CloudRuleIdentifier) { + // We can wait only for one rule at a time + clearRemovalInterval(); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + clearRemovalInterval(); + reject(new Error('Timeout while waiting for rule removal')); + }, CONSISTENCY_CHECK_TIMEOUT); + }); + + const waitPromise = new Promise((resolve, reject) => { + removalConsistencyInterval.current = setInterval(() => { + matchingPromRuleExists(ruleIdentifier) + .then((ruleExists) => { + if (ruleExists === false) { + clearRemovalInterval(); + resolve(); + } + }) + .catch((error) => { + clearRemovalInterval(); + reject(error); + }); + }, CONSISTENCY_CHECK_POOL_INTERVAL); + }); + + return Promise.race([timeoutPromise, waitPromise]); + } + + async function waitForCreation(ruleIdentifier: CloudRuleIdentifier) { + // We can wait only for one rule at a time + clearCreationInterval(); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + clearCreationInterval(); + reject(new Error('Timeout while waiting for rule creation')); + }, CONSISTENCY_CHECK_TIMEOUT); + }); + + const waitPromise = new Promise((resolve, reject) => { + creationConsistencyInterval.current = setInterval(() => { + matchingPromRuleExists(ruleIdentifier) + .then((ruleExists) => { + if (ruleExists === true) { + clearCreationInterval(); + resolve(); + } + }) + .catch((error) => { + clearCreationInterval(); + reject(error); + }); + }, CONSISTENCY_CHECK_POOL_INTERVAL); + }); + + return Promise.race([timeoutPromise, waitPromise]); + } + + return { waitForRemoval, waitForCreation }; +} + +export function usePrometheusCreationConsistencyCheck(ruleIdentifier: RuleIdentifier) { + const { matchingPromRuleExists } = useMatchingPromRuleExists(); + const { waitForCreation } = usePrometheusConsistencyCheck(); + + const [actions, state] = useAsync(async (identifier: RuleIdentifier) => { + if (isCloudRuleIdentifier(identifier)) { + return waitForCreation(identifier); + } else { + // GMA rules are not supported yet + return Promise.resolve(); + } + }); + + useEffect(() => { + if (isCloudRuleIdentifier(ruleIdentifier)) { + // We need to check if the rule exists first, because most of the times it does, + // and wait for the consistency only if the rule does not exist. + matchingPromRuleExists(ruleIdentifier).then((ruleExists) => { + if (!ruleExists) { + actions.execute(ruleIdentifier); + } + }); + } + }, [actions, ruleIdentifier, matchingPromRuleExists]); + + return { isConsistent: state.status === 'success' || state.status === 'not-executed', error: state.error }; +} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index c7dad4ca3b1..77565cfa437 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -37,7 +37,7 @@ import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames, getRulesDataSource } from '../utils/datasource'; import { makeAMLink } from '../utils/misc'; import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; -import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules'; +import { getAlertInfo } from '../utils/rules'; import { safeParsePrometheusDuration } from '../utils/time'; function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { @@ -154,18 +154,6 @@ export function fetchPromAndRulerRulesAction({ }; } -// this will only trigger ruler rules fetch if rules are not loaded yet and request is not in flight -export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkResult { - return (dispatch, getStore) => { - const { rulerRules } = getStore().unifiedAlerting; - const resp = rulerRules[rulesSourceName]; - const emptyResults = isEmpty(resp?.result); - if (emptyResults && !(resp && isRulerNotSupportedResponse(resp)) && !resp?.loading) { - dispatch(fetchRulerRulesAction({ rulesSourceName })); - } - }; -} - // TODO: memoize this or move to RTK Query so we can cache results! export function fetchAllPromBuildInfoAction(): ThunkResult> { return async (dispatch) => { @@ -280,12 +268,15 @@ export function fetchAllPromAndRulerRulesAction( }; } -export function fetchAllPromRulesAction(force = false): ThunkResult { +export function fetchAllPromRulesAction( + force = false, + options: FetchPromRulesRulesActionProps = {} +): ThunkResult> { return async (dispatch, getStore) => { const { promRules } = getStore().unifiedAlerting; getAllRulesSourceNames().map((rulesSourceName) => { if (force || !promRules[rulesSourceName]?.loading) { - dispatch(fetchPromRulesAction({ rulesSourceName })); + dispatch(fetchPromRulesAction({ rulesSourceName, ...options })); } }); }; diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index 1e4fb11524c..eaeeb23c590 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -1,6 +1,6 @@ export const RULER_NOT_SUPPORTED_MSG = 'ruler not supported'; -export const RULE_LIST_POLL_INTERVAL_MS = 20000; +export const RULE_LIST_POLL_INTERVAL_MS = 30000; export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager'; export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager'; diff --git a/public/app/features/alerting/unified/utils/rule-id.test.ts b/public/app/features/alerting/unified/utils/rule-id.test.ts index d03f4b099e9..a24a468c260 100644 --- a/public/app/features/alerting/unified/utils/rule-id.test.ts +++ b/public/app/features/alerting/unified/utils/rule-id.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react-hooks'; +import { config } from '@grafana/runtime'; import { AlertingRule, RecordingRule, RuleIdentifier } from 'app/types/unified-alerting'; import { GrafanaAlertStateDecision, @@ -47,15 +48,6 @@ const recordingRule = { }; describe('hashRulerRule', () => { - it('should not hash unknown rule types', () => { - const unknownRule = {}; - - expect(() => { - // @ts-ignore - hashRulerRule(unknownRule); - }).toThrow('Only recording and alerting ruler rules can be hashed'); - }); - it('should hash recording rules', () => { const recordingRule: RulerRecordingRuleDTO = { record: 'instance:node_num_cpu:sum', @@ -151,6 +143,30 @@ describe('hashRulerRule', () => { it('should throw for malformed identifier', () => { expect(() => parse('foo$bar$baz', false)).toThrow(/failed to parse/i); }); + + describe('when prometheusRulesPrimary is enabled', () => { + beforeAll(() => { + config.featureToggles.alertingPrometheusRulesPrimary = true; + }); + afterAll(() => { + config.featureToggles.alertingPrometheusRulesPrimary = false; + }); + + it('should not take query into account', () => { + const rule1: RulerAlertingRuleDTO = { + ...alertingRule.ruler, + expr: 'vector(20) > 7', + }; + + const rule2: RulerAlertingRuleDTO = { + ...alertingRule.ruler, + expr: 'http_requests_total{node="node1"}', + }; + + expect(rule1.expr).not.toBe(rule2.expr); + expect(hashRulerRule(rule1)).toBe(hashRulerRule(rule2)); + }); + }); }); describe('hashRule', () => { @@ -167,6 +183,30 @@ describe('hashRule', () => { expect(promHash).toBe(rulerHash); }); + + describe('when prometheusRulesPrimary is enabled', () => { + beforeAll(() => { + config.featureToggles.alertingPrometheusRulesPrimary = true; + }); + afterAll(() => { + config.featureToggles.alertingPrometheusRulesPrimary = false; + }); + + it('should not take query into account', () => { + const rule1: AlertingRule = { + ...alertingRule.prom, + query: 'vector(20) > 7', + }; + + const rule2: AlertingRule = { + ...alertingRule.prom, + query: 'http_requests_total{node="node1"}', + }; + + expect(rule1.query).not.toBe(rule2.query); + expect(hashRule(rule1)).toBe(hashRule(rule2)); + }); + }); }); describe('equal', () => { diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index d9a5cd59193..8e44a5a3dc4 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -10,7 +10,9 @@ import { RuleIdentifier, RuleWithLocation, } from 'app/types/unified-alerting'; -import { Annotations, Labels, PromRuleType, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { Annotations, Labels, PromRuleType, RulerCloudRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; + +import { shouldUsePrometheusRulesPrimary } from '../featureToggles'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { @@ -249,18 +251,19 @@ export function hashRulerRule(rule: RulerRuleDTO): string { return hash(JSON.stringify(fingerprint)).toString(); } -function getRulerRuleFingerprint(rule: RulerRuleDTO) { +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 labelsHash = hashLabelsOrAnnotations(rule.labels); + if (isRecordingRulerRule(rule)) { - return [rule.record, PromRuleType.Recording, hashQuery(rule.expr), hashLabelsOrAnnotations(rule.labels)]; + return [rule.record, PromRuleType.Recording, queryHash, labelsHash]; } if (isAlertingRulerRule(rule)) { - return [ - rule.alert, - PromRuleType.Alerting, - hashQuery(rule.expr), - hashLabelsOrAnnotations(rule.annotations), - hashLabelsOrAnnotations(rule.labels), - ]; + return [rule.alert, PromRuleType.Alerting, queryHash, hashLabelsOrAnnotations(rule.annotations), labelsHash]; } throw new Error('Only recording and alerting ruler rules can be hashed'); } @@ -271,17 +274,16 @@ export function hashRule(rule: Rule): string { } function getPromRuleFingerprint(rule: Rule) { + const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + + const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.query); + const labelsHash = hashLabelsOrAnnotations(rule.labels); + if (isRecordingRule(rule)) { - return [rule.name, PromRuleType.Recording, hashQuery(rule.query), hashLabelsOrAnnotations(rule.labels)]; + return [rule.name, PromRuleType.Recording, queryHash, labelsHash]; } if (isAlertingRule(rule)) { - return [ - rule.name, - PromRuleType.Alerting, - hashQuery(rule.query), - hashLabelsOrAnnotations(rule.annotations), - hashLabelsOrAnnotations(rule.labels), - ]; + return [rule.name, PromRuleType.Alerting, queryHash, hashLabelsOrAnnotations(rule.annotations), labelsHash]; } throw new Error('Only recording and alerting rules can be hashed'); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c1dab28f8ed..8d8bebbfc39 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -1,6 +1,5 @@ -import { act, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render } from 'test/test-utils'; import { byTestId } from 'testing-library-selector'; import { DataSourceApi } from '@grafana/data'; @@ -76,11 +75,7 @@ const mocks = { }; const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: ReturnType) => { - render( - - - - ); + render(); }; const promResponse: PromRulesResponse = { @@ -348,9 +343,9 @@ async function clickNewButton() { const oldPush = locationService.push; locationService.push = pushMock; const button = await ui.createButton.find(); - await act(async () => { - await userEvent.click(button); - }); + + await userEvent.click(button); + const match = pushMock.mock.lastCall[0].match(/alerting\/new\?defaults=(.*)&returnTo=/); const defaults = JSON.parse(decodeURIComponent(match![1])); locationService.push = oldPush; diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 4e5418c7305..0c379f929b1 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -132,7 +132,7 @@ export interface PromAlertingRuleDTO extends PromRuleDTOBase { activeAt: string; value: string; }>; - labels: Labels; + labels?: Labels; annotations?: Annotations; duration?: number; // for state: PromAlertingRuleState; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 734e6c650ad..74eb00ee147 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -42,7 +42,7 @@ interface RuleBase { export interface AlertingRule extends RuleBase { alerts?: Alert[]; - labels: { + labels?: { [key: string]: string; }; annotations?: { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b75b66b65e3..4adc53c31a8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -215,6 +215,12 @@ "paused": "Paused", "recording-rule": "Recording rule" }, + "rule-viewer": { + "prometheus-consistency-check": { + "alert-message": "Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view.", + "alert-title": "Update in progress" + } + }, "rules": { "add-rule": { "success": "Rule added successfully" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index a237969e6d4..359abaed9f0 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -215,6 +215,12 @@ "paused": "Päūşęđ", "recording-rule": "Ŗęčőřđįʼnģ řūľę" }, + "rule-viewer": { + "prometheus-consistency-check": { + "alert-message": "Åľęřŧ řūľę ĥäş þęęʼn ūpđäŧęđ. Cĥäʼnģęş mäy ŧäĸę ūp ŧő ä mįʼnūŧę ŧő äppęäř őʼn ŧĥę Åľęřŧ řūľęş ľįşŧ vįęŵ.", + "alert-title": "Ůpđäŧę įʼn přőģřęşş" + } + }, "rules": { "add-rule": { "success": "Ŗūľę äđđęđ şūččęşşƒūľľy"