mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
fcb17379ea
commit
db42af20ca
@ -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
|
||||
|
|
@ -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>
|
||||
{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,8 +34,9 @@ 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 prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
|
||||
const RuleListV1 = () => {
|
||||
const dispatch = useDispatch();
|
||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
@ -78,8 +80,13 @@ const RuleList = withErrorBoundary(
|
||||
// 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(() => {
|
||||
@ -88,7 +95,12 @@ const RuleList = withErrorBoundary(
|
||||
|
||||
// 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);
|
||||
|
||||
@ -123,11 +135,9 @@ const RuleList = withErrorBoundary(
|
||||
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
},
|
||||
{ style: 'page' }
|
||||
);
|
||||
};
|
||||
|
||||
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,9 +297,27 @@ 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);
|
||||
renderCell: ({ data: rule }) => <RuleActionsCell rule={rule} isLoadingRuler={isRulerLoading} />,
|
||||
size: '200px',
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [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
|
||||
@ -243,10 +326,21 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe
|
||||
rulesSource={rule.namespace.rulesSource}
|
||||
/>
|
||||
);
|
||||
},
|
||||
size: '200px',
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, hasRuler, rulerRulesLoaded]);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
3
public/app/features/alerting/unified/featureToggles.ts
Normal file
3
public/app/features/alerting/unified/featureToggles.ts
Normal file
@ -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,6 +164,16 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
loading,
|
||||
} = useIsRuleEditable(rulesSourceName, rule.rulerRule);
|
||||
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
|
||||
const canSilence = useCanSilence(rule);
|
||||
|
||||
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;
|
||||
@ -178,7 +183,6 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported;
|
||||
|
||||
const rulesPermissions = getRulesPermissions(rulesSourceName);
|
||||
const canSilence = useCanSilence(rule);
|
||||
|
||||
const abilities: Abilities<AlertRuleAction> = {
|
||||
[AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create),
|
||||
@ -191,6 +195,9 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
[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] } : {};
|
||||
|
||||
const combinedNamespaces = combineRulesNamespaces(ruleSource, promRuleNs, rulerConfig);
|
||||
const combinedRules = combinedNamespaces.flatMap((ns) => ns.groups).flatMap((group) => group.rules);
|
||||
|
||||
const matchingRule = combinedRules.find((rule) =>
|
||||
ruleId.equal(ruleId.fromCombinedRule(ruleSourceName, rule), ruleIdentifier)
|
||||
);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
for (const group of namespace.groups) {
|
||||
for (const rule of group.rules) {
|
||||
const id = ruleId.fromCombinedRule(ruleSourceName, rule);
|
||||
|
||||
if (ruleId.equal(id, ruleIdentifier)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
// datasource has ruler if the discovery api returns a rulerConfig
|
||||
export function useHasRuler(rulesSource: RulesSource) {
|
||||
const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
|
||||
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 rulerRulesLoaded = useCallback(
|
||||
(rulesSource: RulesSource) => {
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
const result = rulerRules[rulesSourceName]?.result;
|
||||
|
||||
return Boolean(result);
|
||||
},
|
||||
[rulerRules]
|
||||
);
|
||||
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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…
Reference in New Issue
Block a user