Alerting: Prometheus primary mode for the alert list page (#92975)

* Lazy loading of mimir groups

* Refactor rule statuses

* Use prometheus endpoint to populate namespace and group dropdowns

* Add a feature toggle

* Use lazy loading ruler rules if the feature toggle enabled

* Remove unnecessary props form dynamic table

* Remove query from hash calculation

* Conditionally load ns and group autocompletions from Prom or Ruler APIs

* Fix prometheus dto labels property type

* Add a new suggestions hook which provides autocomplete options for the alert rule form

* Improve delete status handling

* Add waiting for Prometheus endpoint consistency after update submission

* Get rule definition from ruler or prometheus endpoint in useCombinedRule

* Add Prometheus consistency check. Fix view page redirects

* Remove rules reload after rule creation, remove statuses from Prom primary mode

* Add waiting for Prometheus consistency on delete rule action

* Add groups list rendering improvements

* Add memo to useAbilities

* Fix GMA consistency check, fix GMA statuses

* defer filered rules rendering

* Update failing tests

* Update locales

* Add rule-id tests

* Remove unused action

* update loading styles

* Fix unrelated test

* Add a new object for reading alerting feature toggles, address minor review issues

* Improve consistency check

* update i18n

* Improve rule form redirects

* Refactor feature toggle handling

* Update docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md

Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com>

* Update public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx

Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com>

* Fix prettier issues

* Fix i18n

* Fix the feature toggle description

* Fix rule updates, fix ruler-based suggestions, wait for deletion for GMA rules

* Fix rename

* Remove unused code, improve copy

* Update i18n

* Fix url redirect when serving from subpath

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com>
pull/93908/head
Konrad Lalik 9 months ago committed by GitHub
parent fcb17379ea
commit db42af20ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 7
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 16
      pkg/services/featuremgmt/toggles_gen.json
  7. 12
      public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
  8. 3
      public/app/features/alerting/unified/RuleList.test.tsx
  9. 11
      public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap
  10. 11
      public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap
  11. 1
      public/app/features/alerting/unified/components/DynamicTable.tsx
  12. 38
      public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx
  13. 63
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
  14. 63
      public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx
  15. 134
      public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx
  16. 174
      public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx
  17. 18
      public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx
  18. 51
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
  19. 10
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
  20. 14
      public/app/features/alerting/unified/components/rules/RuleStats.tsx
  21. 4
      public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
  22. 59
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  23. 182
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  24. 3
      public/app/features/alerting/unified/featureToggles.ts
  25. 7
      public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts
  26. 67
      public/app/features/alerting/unified/hooks/useAbilities.ts
  27. 38
      public/app/features/alerting/unified/hooks/useCombinedRule.ts
  28. 27
      public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
  29. 9
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  30. 31
      public/app/features/alerting/unified/hooks/useHasRuler.ts
  31. 162
      public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts
  32. 21
      public/app/features/alerting/unified/state/actions.ts
  33. 2
      public/app/features/alerting/unified/utils/constants.ts
  34. 58
      public/app/features/alerting/unified/utils/rule-id.test.ts
  35. 38
      public/app/features/alerting/unified/utils/rule-id.ts
  36. 15
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
  37. 2
      public/app/types/unified-alerting-dto.ts
  38. 2
      public/app/types/unified-alerting.ts
  39. 6
      public/locales/en-US/grafana.json
  40. 6
      public/locales/pseudo-LOCALE/grafana.json

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

@ -204,6 +204,7 @@ export interface FeatureToggles {
dataplaneAggregator?: boolean;
newFiltersUI?: boolean;
lokiSendDashboardPanelNames?: boolean;
alertingPrometheusRulesPrimary?: boolean;
singleTopNav?: boolean;
exploreLogsShardSplitting?: boolean;
exploreLogsAggregatedMetrics?: boolean;

@ -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",

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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
185 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
186 newFiltersUI experimental @grafana/dashboards-squad false false false
187 lokiSendDashboardPanelNames experimental @grafana/observability-logs false false false
188 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
189 singleTopNav experimental @grafana/grafana-frontend-platform false false true
190 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
191 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true

@ -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"

@ -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",

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

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

@ -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": [

@ -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": [

@ -43,7 +43,6 @@ export interface DynamicTableProps<T = unknown> {
onCollapse?: (item: DynamicTableItemProps<T>) => void;
onExpand?: (item: DynamicTableItemProps<T>) => void;
isExpanded?: (item: DynamicTableItemProps<T>) => boolean;
renderExpandedContent?: (
item: DynamicTableItemProps<T>,
index: number,

@ -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<RuleFormValues>();
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<SelectableValue<string>> =>
rulesConfig ? Object.keys(rulesConfig).map((namespace) => ({ label: namespace, value: namespace })) : [],
[rulesConfig]
const namespaceOptions: Array<SelectableValue<string>> = useMemo(
() =>
Array.from(namespaceGroups.keys()).map((namespace) => ({
label: namespace,
value: namespace,
})),
[namespaceGroups]
);
const groupOptions = useMemo(
(): Array<SelectableValue<string>> =>
(namespace && rulesConfig?.[namespace]?.map((group) => ({ label: group.name, value: group.name }))) || [],
[namespace, rulesConfig]
const groupOptions: Array<SelectableValue<string>> = 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"

@ -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<RuleFormValues>; // 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<boolean>(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 && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule and exit
</Button>
<Link to={returnTo}>
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
Cancel
</Button>
</Link>
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
Cancel
</Button>
{existing ? (
<Button fill="outline" variant="destructive" type="button" onClick={() => setShowDeleteModal(true)} size="sm">
Delete
@ -312,6 +324,27 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
);
};
function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) {
const { dataSourceName, namespaceName, groupName } = groupId;
if (prometheusRulesPrimary && isCloudRulerRule(rule)) {
const ruleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule);
return createViewLinkFromIdentifier(ruleIdentifier);
}
// TODO We could add namespace and group filters but for GMA the namespace = uid which doesn't work with the filters
return '/alerting/list';
}
// The result of this function is passed to locationService.push()
// Hence it cannot contain the subpath prefix, so we cannot use createRelativeUrl for it
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(identifier.ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
const isCortexLokiOrRecordingRule = (watch: UseFormWatch<RuleFormValues>) => {
const [ruleType, dataSourceName] = watch(['type', 'dataSourceName']);

@ -1,21 +1,19 @@
import { css, cx } from '@emotion/css';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useMemo, useState } from 'react';
import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Space, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { labelsApi } from '../../../api/labelsApi';
import { usePluginBridge } from '../../../hooks/usePluginBridge';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../../state/actions';
import { SupportedPlugin } from '../../../types/pluginBridges';
import { RuleFormValues } from '../../../types/rule-form';
import { isPrivateLabelKey } from '../../../utils/labels';
import AlertLabelDropdown from '../../AlertLabelDropdown';
import { AlertLabels } from '../../AlertLabels';
import { NeedHelpInfo } from '../NeedHelpInfo';
import { useAlertRuleSuggestions } from '../useAlertRuleSuggestions';
import { AddButton, RemoveButton } from './LabelsButtons';
@ -25,52 +23,6 @@ const useGetOpsLabelsKeys = (skip: boolean) => {
});
return { loading: isloadingLabels, labelsOpsKeys: currentData };
};
const useGetAlertRulesLabels = (
dataSourceName: string
): { loading: boolean; labelsByKey: Record<string, Set<string>> } => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchRulerRulesIfNotFetchedYet(dataSourceName));
}, [dispatch, dataSourceName]);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const rulerRequest = rulerRuleRequests[dataSourceName];
const labelsByKeyResult = useMemo<Record<string, Set<string>>>(() => {
const labelsByKey: Record<string, Set<string>> = {};
const rulerRulesConfig = rulerRequest?.result;
if (!rulerRulesConfig) {
return labelsByKey;
}
const allRules = Object.values(rulerRulesConfig)
.flatMap((groups) => groups)
.flatMap((group) => group.rules);
allRules.forEach((rule) => {
if (rule.labels) {
Object.entries(rule.labels).forEach(([key, value]) => {
if (!value) {
return;
}
const labelEntry = labelsByKey[key];
if (labelEntry) {
labelEntry.add(value);
} else {
labelsByKey[key] = new Set([value]);
}
});
}
});
return labelsByKey;
}, [rulerRequest]);
return { loading: rulerRequest?.loading, labelsByKey: labelsByKeyResult };
};
function mapLabelsToOptions(
items: Iterable<string> = [],
@ -158,7 +110,7 @@ export function useCombinedLabels(
selectedKey: string
) {
// ------- Get labels keys and their values from existing alerts
const { loading, labelsByKey: labelsByKeyFromExisingAlerts } = useGetAlertRulesLabels(dataSourceName);
const { isLoading, labels: labelsByKeyFromExisingAlerts } = useAlertRuleSuggestions(dataSourceName);
// ------- Get only the keys from the ops labels, as we will fetch the values for the keys once the key is selected.
const { loading: isLoadingLabels, labelsOpsKeys = [] } = useGetOpsLabelsKeys(
!labelsPluginInstalled || loadingLabelsPlugin
@ -178,7 +130,7 @@ export function useCombinedLabels(
//------- Convert the keys from the existing alerts to options for the dropdown
const keysFromExistingAlerts = useMemo(() => {
return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts).filter(isKeyAllowed), labelsInSubform);
return mapLabelsToOptions(Array.from(labelsByKeyFromExisingAlerts.keys()).filter(isKeyAllowed), labelsInSubform);
}, [labelsByKeyFromExisingAlerts, labelsInSubform]);
// create two groups of labels, one for ops and one for custom
@ -195,8 +147,7 @@ export function useCombinedLabels(
},
];
const selectedKeyIsFromAlerts =
labelsByKeyFromExisingAlerts[selectedKey] !== undefined && labelsByKeyFromExisingAlerts[selectedKey]?.size > 0;
const selectedKeyIsFromAlerts = labelsByKeyFromExisingAlerts.has(selectedKey);
const selectedKeyIsFromOps = labelsByKeyOps[selectedKey] !== undefined && labelsByKeyOps[selectedKey]?.size > 0;
const selectedKeyDoesNotExist = !selectedKeyIsFromAlerts && !selectedKeyIsFromOps;
@ -248,7 +199,7 @@ export function useCombinedLabels(
// values from existing alerts will take precedence over values from ops
if (selectedKeyIsFromAlerts || !labelsPluginInstalled) {
return mapLabelsToOptions(labelsByKeyFromExisingAlerts[key]);
return mapLabelsToOptions(labelsByKeyFromExisingAlerts.get(key));
}
return valuesFromSelectedGopsKey;
},
@ -256,7 +207,7 @@ export function useCombinedLabels(
);
return {
loading: loading || isLoadingLabels,
loading: isLoading || isLoadingLabels,
keysFromExistingAlerts,
groupedOptions,
getValuesForLabel,

@ -0,0 +1,134 @@
import { useEffect, useMemo } from 'react';
import { RuleNamespace } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
const { usePrometheusRuleNamespacesQuery, useLazyRulerRulesQuery } = alertRuleApi;
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const emptyRulerConfig: RulerRulesConfigDTO = {};
export function useAlertRuleSuggestions(rulesSourceName: string) {
const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery({ rulesSourceName });
// emptyRulerConfig is used to prevent from triggering labels' useMemo all the time
// rulerRules = {} creates a new object and triggers useMemo to recalculate labels
const [fetchRulerRules, { data: rulerRules = emptyRulerConfig, isLoading: isRulerRulesLoading }] =
useLazyRulerRulesQuery();
const { data: promNamespaces = [], isLoading: isPrometheusRulesLoading } = usePrometheusRuleNamespacesQuery(
{ ruleSourceName: rulesSourceName },
{ skip: !prometheusRulesPrimary }
);
useEffect(() => {
if (features?.rulerConfig && !prometheusRulesPrimary) {
fetchRulerRules({ rulerConfig: features.rulerConfig });
}
}, [features?.rulerConfig, fetchRulerRules]);
const namespaceGroups = useMemo(() => {
if (isPrometheusRulesLoading || isRulerRulesLoading) {
return new Map<string, string[]>();
}
if (prometheusRulesPrimary) {
return promNamespacesToNamespaceGroups(promNamespaces);
}
return rulerRulesToNamespaceGroups(rulerRules);
}, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]);
const labels = useMemo(() => {
if (isPrometheusRulesLoading || isRulerRulesLoading) {
return new Map<string, Set<string>>();
}
if (prometheusRulesPrimary) {
return promNamespacesToLabels(promNamespaces);
}
return rulerRulesToLabels(rulerRules);
}, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]);
return { namespaceGroups, labels, isLoading: isPrometheusRulesLoading || isRulerRulesLoading || isFeaturesLoading };
}
function promNamespacesToNamespaceGroups(promNamespaces: RuleNamespace[]) {
const groups = new Map<string, string[]>();
promNamespaces.forEach((namespace) => {
groups.set(
namespace.name,
namespace.groups.map((group) => group.name)
);
});
return groups;
}
function rulerRulesToNamespaceGroups(rulerConfig: RulerRulesConfigDTO) {
const result = new Map<string, string[]>();
Object.entries(rulerConfig).forEach(([namespace, groups]) => {
result.set(
namespace,
groups.map((group) => group.name)
);
});
return result;
}
function promNamespacesToLabels(promNamespace: RuleNamespace[]) {
const rules = promNamespace.flatMap((namespace) => namespace.groups).flatMap((group) => group.rules);
return rules.reduce((result, rule) => {
if (!rule.labels) {
return result;
}
Object.entries(rule.labels).forEach(([labelKey, labelValue]) => {
if (!labelKey || !labelValue) {
return;
}
const labelEntry = result.get(labelKey);
if (labelEntry) {
labelEntry.add(labelValue);
} else {
result.set(labelKey, new Set([labelValue]));
}
});
return result;
}, new Map<string, Set<string>>());
}
function rulerRulesToLabels(rulerConfig: RulerRulesConfigDTO) {
const result = new Map<string, Set<string>>();
const rules = Object.entries(rulerConfig)
.flatMap(([_, groups]) => groups)
.flatMap((group) => group.rules);
return rules.reduce((result, rule) => {
if (!rule.labels) {
return result;
}
Object.entries(rule.labels).forEach(([labelKey, labelValue]) => {
if (!labelKey || !labelValue) {
return;
}
const labelEntry = result.get(labelKey);
if (labelEntry) {
labelEntry.add(labelValue);
} else {
result.set(labelKey, new Set([labelValue]));
}
});
return result;
}, result);
}

@ -9,13 +9,14 @@ import { useDispatch } from 'app/types';
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants';
import { getAllRulesSourceNames } from '../../utils/datasource';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import RulesFilter from '../rules/Filter/RulesFilter';
import { NoRulesSplash } from '../rules/NoRulesCTA';
@ -33,101 +34,110 @@ const VIEWS = {
// make sure we ask for 1 more so we show the "show x more" button
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
const RuleList = withErrorBoundary(
() => {
const dispatch = useDispatch();
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [expandAll, setExpandAll] = useState(false);
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const onFilterCleared = useCallback(() => setExpandAll(false), []);
const RuleListV1 = () => {
const dispatch = useDispatch();
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [expandAll, setExpandAll] = useState(false);
const [queryParams] = useQueryParams();
const { filterState, hasActiveFilters } = useRulesFilter();
const onFilterCleared = useCallback(() => setExpandAll(false), []);
const queryParamView = queryParams.view as keyof typeof VIEWS;
const view = VIEWS[queryParamView] ? queryParamView : 'groups';
const [queryParams] = useQueryParams();
const { filterState, hasActiveFilters } = useRulesFilter();
const ViewComponent = VIEWS[view];
const queryParamView = queryParams.view as keyof typeof VIEWS;
const view = VIEWS[queryParamView] ? queryParamView : 'groups';
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const ViewComponent = VIEWS[view];
const loading = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
);
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const promRequests = Object.entries(promRuleRequests);
const rulerRequests = Object.entries(rulerRuleRequests);
const loading = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
);
const allPromLoaded = promRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const allRulerLoaded = rulerRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const promRequests = Object.entries(promRuleRequests);
const rulerRequests = Object.entries(rulerRuleRequests);
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
const allPromLoaded = promRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const allRulerLoaded = rulerRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const allRulerEmpty = rulerRequests.every(([_, state]) => {
const rulerRules = Object.entries(state?.result ?? {});
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
return noRules && state.dispatched;
});
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
const [_, fetchRules] = useAsyncFn(async () => {
if (!loading) {
const allRulerEmpty = rulerRequests.every(([_, state]) => {
const rulerRules = Object.entries(state?.result ?? {});
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
return noRules && state.dispatched;
});
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
const [_, fetchRules] = useAsyncFn(async () => {
if (!loading) {
if (prometheusRulesPrimary) {
await dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
await dispatch(fetchAllPromRulesAction(false, { limitAlerts }));
} else {
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}
}, [loading, limitAlerts, dispatch]);
useEffect(() => {
trackRuleListNavigation().catch(() => {});
}, []);
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
}
}, [loading, limitAlerts, dispatch]);
useEffect(() => {
trackRuleListNavigation().catch(() => {});
}, []);
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
if (prometheusRulesPrimary) {
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
dispatch(fetchAllPromRulesAction(false, { limitAlerts }));
} else {
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}, [dispatch, limitAlerts]);
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
// Show splash only when we loaded all of the data sources and none of them has alerts
const hasNoAlertRulesCreatedYet =
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
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
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
<RuleListErrors />
<RulesFilter onClear={onFilterCleared} />
{hasAlertRulesCreated && (
<Stack direction="row" alignItems="center">
{view === 'groups' && hasActiveFilters && (
<Button
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
variant="secondary"
onClick={() => setExpandAll(!expandAll)}
>
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
<RuleStats namespaces={filteredNamespaces} />
</Stack>
)}
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
</AlertingPageWrapper>
);
},
{ style: 'page' }
);
}
}, [dispatch, limitAlerts]);
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
// Show splash only when we loaded all of the data sources and none of them has alerts
const hasNoAlertRulesCreatedYet =
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
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
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
<RuleListErrors />
<RulesFilter onClear={onFilterCleared} />
{hasAlertRulesCreated && (
<Stack direction="row" alignItems="center">
{view === 'groups' && hasActiveFilters && (
<Button
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
variant="secondary"
onClick={() => setExpandAll(!expandAll)}
>
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
<RuleStats namespaces={filteredNamespaces} />
</Stack>
)}
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
</AlertingPageWrapper>
);
};
export default RuleList;
export default withErrorBoundary(RuleListV1, { style: 'page' });
export function CreateAlertButton() {
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);

@ -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<CombinedRule | undefined>();
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(

@ -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 = () => {
</Stack>
}
>
{prometheusRulesPrimary && <PrometheusConsistencyCheck ruleIdentifier={identifier} />}
<Stack direction="column" gap={2}>
{/* tabs and tab content */}
<TabContent>
@ -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<HTMLDivElement>();
const { isConsistent, error } = usePrometheusCreationConsistencyCheck(ruleIdentifier);
if (isConsistent) {
return null;
}
if (error) {
return (
<Alert title="Unable to check the rule status" bottomSpacing={0} topSpacing={2}>
{stringifyErrorLike(error)}
</Alert>
);
}
return (
<Stack direction="column" gap={0} ref={ref}>
<LoadingBar width={width} />
<Alert
title={t('alerting.rule-viewer.prometheus-consistency-check.alert-title', 'Update in progress')}
severity="info"
>
<Trans i18nKey="alerting.rule-viewer.prometheus-consistency-check.alert-message">
Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view.
</Trans>
</Alert>
</Stack>
);
}
export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err';
export function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] {

@ -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'}
</LinkButton>
@ -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(
<LinkButton

@ -1,6 +1,6 @@
import { isUndefined, omitBy, pick, sum } from 'lodash';
import pluralize from 'pluralize';
import { Fragment } from 'react';
import { Fragment, useDeferredValue, useMemo } from 'react';
import * as React from 'react';
import { Badge, Stack } from '@grafana/ui';
@ -27,8 +27,12 @@ const emptyStats: Required<AlertGroupTotals> = {
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) => {
)}
</Stack>
);
};
});
RuleStats.displayName = 'RuleStats';
interface RuleGroupStatsProps {
group: CombinedRuleGroup;

@ -40,8 +40,8 @@ const mocks = {
function mockUseHasRuler(hasRuler: boolean, rulerRulesLoaded: boolean) {
mocks.useHasRuler.mockReturnValue({
hasRuler: () => hasRuler,
rulerRulesLoaded: () => rulerRulesLoaded,
hasRuler,
rulerRulesLoaded,
});
}

@ -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(
<ActionIcon
@ -231,16 +229,8 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
onToggle={setIsCollapsed}
data-testid={selectors.components.AlertRules.groupToggle}
/>
<Icon name={isCollapsed ? 'folder' : 'folder-open'} />
{isCloudRulesSource(rulesSource) && (
<Tooltip content={rulesSource.name} placement="top">
<img
alt={rulesSource.meta.name}
className={styles.dataSourceIcon}
src={rulesSource.meta.info.logos.small}
/>
</Tooltip>
)}
<FolderIcon isCollapsed={isCollapsed} />
<CloudSourceLogo rulesSource={rulesSource} />
{
// eslint-disable-next-line
<div className={styles.groupName} onClick={() => 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 (
<Tooltip content={rulesSource.name} placement="top">
<img alt={rulesSource.meta.name} className={styles.dataSourceIcon} src={rulesSource.meta.info.logos.small} />
</Tooltip>
);
}
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 <Icon name={isCollapsed ? 'folder' : 'folder-open'} />;
});
FolderIcon.displayName = 'FolderIcon';
export const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({}),

@ -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 <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
}
@ -73,13 +91,71 @@ export const RulesTable = ({
isExpandable={true}
items={items}
renderExpandedContent={({ data: rule }) => <RuleDetails rule={rule} />}
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
paginationStyles={styles.pagination}
/>
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
className={styles.pagination}
/>
</div>
);
};
/**
* 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 <RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} isPaused={isPaused} />;
},
renderCell: ({ data: rule }) => <RuleStateCell rule={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 (
<RuleActionsButtons
compact
showViewButton={!isDeleting && !isCreating}
rule={rule}
rulesSource={rule.namespace.rulesSource}
/>
);
},
renderCell: ({ data: rule }) => <RuleActionsCell rule={rule} isLoadingRuler={isRulerLoading} />,
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 <RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} isPaused={isPaused} />;
}
function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadingRuler: boolean }) {
const styles = useStyles2(getStyles);
const { isDeleting, isCreating } = useRuleStatus(rule);
if (isLoadingRuler) {
return <Skeleton containerClassName={styles.skeletonWrapper} />;
}
return (
<RuleActionsButtons
compact
showViewButton={!isDeleting && !isCreating}
rule={rule}
rulesSource={rule.namespace.rulesSource}
/>
);
}
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 };
}

@ -0,0 +1,3 @@
import { config } from '@grafana/runtime';
export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false;

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

@ -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<AlertRuleAction> {
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<AlertRul
loading,
} = useIsRuleEditable(rulesSourceName, rule.rulerRule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
// 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 canSilence = useCanSilence(rule);
const abilities: Abilities<AlertRuleAction> = {
[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<Abilities<AlertRuleAction>>(() => {
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> = {
[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;
}

@ -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<RuleLocation> {
export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocation> {
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<RuleLocat
if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
return {
result: {
datasource: ruleIdentifier.ruleSourceName,
namespace: ruleIdentifier.namespace,
group: ruleIdentifier.groupName,
ruleName: ruleIdentifier.ruleName,
@ -202,6 +195,7 @@ function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocat
if (currentData) {
return {
result: {
datasource: ruleIdentifier.ruleSourceName,
namespace: currentData.grafana_alert.namespace_uid,
group: currentData.grafana_alert.rule_group,
ruleName: currentData.grafana_alert.title,

@ -182,6 +182,33 @@ export function attachRulerRulesToCombinedRules(
return ns;
}
export function attachRulerRuleToCombinedRule(rule: CombinedRule, rulerGroup: RulerRuleGroupDTO): void {
if (!rule.promRule) {
return;
}
const combinedRulesFromRuler = rulerGroup.rules.map((rulerRule) =>
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<string, CombinedRule[]>());
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[],

@ -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 = (

@ -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 rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules);
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const hasRuler = useCallback(
(rulesSource: string | RulesSource) => {
const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name;
return rulesSourceName === GRAFANA_RULES_SOURCE_NAME || !!rulerRules[rulesSourceName]?.result;
},
[rulerRules]
);
// 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 rulerRulesLoaded = useCallback(
(rulesSource: RulesSource) => {
const rulesSourceName = getRulesSourceName(rulesSource);
const result = rulerRules[rulesSourceName]?.result;
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
return Boolean(result);
},
[rulerRules]
);
const hasRuler = Boolean(dsFeatures?.rulerConfig);
const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result);
return { hasRuler, rulerRulesLoaded };
}

@ -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<number | undefined>();
const creationConsistencyInterval = useRef<number | undefined>();
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<void>((_, reject) => {
setTimeout(() => {
clearRemovalInterval();
reject(new Error('Timeout while waiting for rule removal'));
}, CONSISTENCY_CHECK_TIMEOUT);
});
const waitPromise = new Promise<void>((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<void>((_, reject) => {
setTimeout(() => {
clearCreationInterval();
reject(new Error('Timeout while waiting for rule creation'));
}, CONSISTENCY_CHECK_TIMEOUT);
});
const waitPromise = new Promise<void>((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 };
}

@ -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<void> {
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<Promise<void>> {
return async (dispatch) => {
@ -280,12 +268,15 @@ export function fetchAllPromAndRulerRulesAction(
};
}
export function fetchAllPromRulesAction(force = false): ThunkResult<void> {
export function fetchAllPromRulesAction(
force = false,
options: FetchPromRulesRulesActionProps = {}
): ThunkResult<Promise<void>> {
return async (dispatch, getStore) => {
const { promRules } = getStore().unifiedAlerting;
getAllRulesSourceNames().map((rulesSourceName) => {
if (force || !promRules[rulesSourceName]?.loading) {
dispatch(fetchPromRulesAction({ rulesSourceName }));
dispatch(fetchPromRulesAction({ rulesSourceName, ...options }));
}
});
};

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

@ -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', () => {

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

@ -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<typeof configureStore>) => {
render(
<TestProvider store={initialStore}>
<PanelDataAlertingTabRendered model={model}></PanelDataAlertingTabRendered>
</TestProvider>
);
render(<PanelDataAlertingTabRendered model={model} />);
};
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;

@ -132,7 +132,7 @@ export interface PromAlertingRuleDTO extends PromRuleDTOBase {
activeAt: string;
value: string;
}>;
labels: Labels;
labels?: Labels;
annotations?: Annotations;
duration?: number; // for
state: PromAlertingRuleState;

@ -42,7 +42,7 @@ interface RuleBase {
export interface AlertingRule extends RuleBase {
alerts?: Alert[];
labels: {
labels?: {
[key: string]: string;
};
annotations?: {

@ -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"

@ -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"

Loading…
Cancel
Save