Alerting: Enhance Ruler and Prometheus group synchronization (#99012)
* Add group actions menu * Refactor modals to accept raw ruler group * Use prometheus and ruler responses to dispaly GMA rules in the hierarchical view * Add groups loader component for data source managed rules * Improve rules matching algorithm for the search results * Use plus and minus icons for reconciliation state * loading spinner WIP for operations / transactions * update comment * Use ruler rules order when displaying a group, change rurler preload behaviour * Add ruler-based ordering for GMA rules * Refactor ruler API mocking * Refactor rule components to accept ruler only rules * Add tests for GrafanaGroupLoader * Add tests for vanilla prom groups * Unify data matching code, add tests for DS groups loader * Fix errors after rebasing * Improve handling of ruler group absence * Fix cache key * Add group action buttons for the new group pages * Add new rule action buttons to the new list page * Address PR feeback, component renaming, missing translations * Unify groups and rules links and actions * Fix new rule button * Add rule list action buttons tests * Fix lint errors * Add redirect to rule details page on save * Update FilterView tests * Fix imports and remove unused code * Improve type definitions, add pooling to Prom hooks, add inline docs * Remove unused code of group modals * Update translations * Disable cache population for filter-view generators * Add consistency check Alert to the RuleViewer when V2 list is enabled * Disable UI errors in prom generator * Improve missing datasouce handling * Add missing translations * Improve group loader tests, remove unused code * Enhance Prometheus API query to include notification options * Improve error handling, remove consistency check for vanilla prom data sources * Address PR feedback, add new version of the useHasRuler hook --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
co-authored by
Gilles De Mey
parent
80fb267cd5
commit
3e15459d20
@@ -2073,19 +2073,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
@@ -2128,9 +2115,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
|
||||
@@ -114,6 +114,7 @@ export const availableIconsIndex = {
|
||||
'file-copy-alt': true,
|
||||
'file-download': true,
|
||||
'file-edit-alt': true,
|
||||
'file-export': true,
|
||||
'file-landscape-alt': true,
|
||||
filter: true,
|
||||
flip: true,
|
||||
|
||||
@@ -3,12 +3,7 @@ import { set } from 'lodash';
|
||||
import { RelativeTimeRange } from '@grafana/data';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import {
|
||||
GrafanaRuleGroupIdentifier,
|
||||
RuleIdentifier,
|
||||
RuleNamespace,
|
||||
RulerDataSourceConfig,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import {
|
||||
AlertQuery,
|
||||
Annotations,
|
||||
@@ -29,6 +24,7 @@ import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, isGrafanaRulesSource }
|
||||
import { arrayKeyValuesToObject } from '../utils/labels';
|
||||
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier, rulerRuleType } from '../utils/rules';
|
||||
|
||||
import { RulerGroupUpdatedResponse } from './alertRuleModel';
|
||||
import { WithNotificationOptions, alertingApi } from './alertingApi';
|
||||
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
|
||||
import {
|
||||
@@ -91,14 +87,6 @@ interface ExportRulesParams {
|
||||
ruleUid?: string;
|
||||
}
|
||||
|
||||
export interface AlertGroupUpdated {
|
||||
message: string;
|
||||
/**
|
||||
* UIDs of rules updated from this request
|
||||
*/
|
||||
updated: string[];
|
||||
}
|
||||
|
||||
export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
preview: build.mutation<
|
||||
@@ -272,16 +260,18 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
],
|
||||
}),
|
||||
|
||||
getGrafanaRulerGroup: build.query<RulerRuleGroupDTO<RulerGrafanaRuleDTO>, GrafanaRuleGroupIdentifier>({
|
||||
query: ({ namespace, groupName }) => {
|
||||
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespaceGroup(namespace.uid, groupName);
|
||||
return { url: path, params };
|
||||
},
|
||||
providesTags: (_result, _error, { namespace, groupName }) => [
|
||||
{ type: 'RuleGroup', id: `grafana/${namespace.uid}/${groupName}` },
|
||||
{ type: 'RuleNamespace', id: `grafana/${namespace.uid}` },
|
||||
],
|
||||
}),
|
||||
getGrafanaRulerGroup: build.query<RulerRuleGroupDTO<RulerGrafanaRuleDTO>, { folderUid: string; groupName: string }>(
|
||||
{
|
||||
query: ({ folderUid, groupName }) => {
|
||||
const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespaceGroup(folderUid, groupName);
|
||||
return { url: path, params };
|
||||
},
|
||||
providesTags: (_result, _error, { folderUid, groupName }) => [
|
||||
{ type: 'RuleGroup', id: `grafana/${folderUid}/${groupName}` },
|
||||
{ type: 'RuleNamespace', id: `grafana/${folderUid}` },
|
||||
],
|
||||
}
|
||||
),
|
||||
|
||||
deleteRuleGroupFromNamespace: build.mutation<
|
||||
RulerRuleGroupDTO,
|
||||
@@ -313,7 +303,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
upsertRuleGroupForNamespace: build.mutation<
|
||||
AlertGroupUpdated,
|
||||
RulerGroupUpdatedResponse,
|
||||
WithNotificationOptions<{
|
||||
rulerConfig: RulerDataSourceConfig;
|
||||
namespace: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface GrafanaGroupUpdatedResponse {
|
||||
message: string;
|
||||
/**
|
||||
* UIDs of rules created from this request
|
||||
*/
|
||||
created?: string[];
|
||||
/**
|
||||
* UIDs of rules updated from this request
|
||||
*/
|
||||
updated?: string[];
|
||||
}
|
||||
|
||||
export interface CloudGroupUpdatedResponse {
|
||||
error: string;
|
||||
errorType: string;
|
||||
status: 'error' | 'success';
|
||||
}
|
||||
|
||||
export type RulerGroupUpdatedResponse = GrafanaGroupUpdatedResponse | CloudGroupUpdatedResponse;
|
||||
|
||||
export function isGrafanaGroupUpdatedResponse(
|
||||
response: RulerGroupUpdatedResponse
|
||||
): response is GrafanaGroupUpdatedResponse {
|
||||
return 'message' in response;
|
||||
}
|
||||
|
||||
export function isCloudGroupUpdatedResponse(
|
||||
response: RulerGroupUpdatedResponse
|
||||
): response is CloudGroupUpdatedResponse {
|
||||
return 'status' in response;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { GrafanaPromRuleGroupDTO, PromRuleDTO, PromRuleGroupDTO } from 'app/type
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
import { WithNotificationOptions, alertingApi } from './alertingApi';
|
||||
import { normalizeRuleGroup } from './prometheus';
|
||||
|
||||
export interface PromRulesResponse<TRuleGroup> {
|
||||
@@ -15,7 +15,7 @@ export interface PromRulesResponse<TRuleGroup> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PromRulesOptions {
|
||||
type PromRulesOptions = WithNotificationOptions<{
|
||||
ruleSource: { uid: string };
|
||||
namespace?: string;
|
||||
groupName?: string;
|
||||
@@ -23,9 +23,10 @@ interface PromRulesOptions {
|
||||
groupLimit?: number;
|
||||
excludeAlerts?: boolean;
|
||||
groupNextToken?: string;
|
||||
}
|
||||
}>;
|
||||
|
||||
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
|
||||
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace'> & {
|
||||
folderUid?: string;
|
||||
dashboardUid?: string;
|
||||
panelId?: number;
|
||||
};
|
||||
@@ -33,20 +34,33 @@ type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
|
||||
export const prometheusApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getGroups: build.query<PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>, PromRulesOptions>({
|
||||
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => {
|
||||
query: ({
|
||||
ruleSource,
|
||||
namespace,
|
||||
groupName,
|
||||
ruleName,
|
||||
groupLimit,
|
||||
excludeAlerts,
|
||||
groupNextToken,
|
||||
notificationOptions,
|
||||
}) => {
|
||||
if (ruleSource.uid === GRAFANA_RULES_SOURCE_NAME) {
|
||||
throw new Error('Please use getGrafanaGroups endpoint for grafana rules');
|
||||
}
|
||||
return {
|
||||
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
|
||||
params: {
|
||||
'file[]': namespace,
|
||||
'group[]': groupName,
|
||||
'rule[]': ruleName,
|
||||
file: namespace, // Mimir
|
||||
'file[]': namespace, // Prometheus
|
||||
rule_group: groupName, // Mimir
|
||||
'rule_group[]': groupName, // Prometheus
|
||||
rule_name: ruleName, // Mimir
|
||||
'rule_name[]': ruleName, // Prometheus
|
||||
exclude_alerts: excludeAlerts?.toString(),
|
||||
group_limit: groupLimit?.toFixed(0),
|
||||
group_next_token: groupNextToken,
|
||||
},
|
||||
notificationOptions,
|
||||
};
|
||||
},
|
||||
transformResponse: (response: PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>) => {
|
||||
@@ -54,12 +68,12 @@ export const prometheusApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
getGrafanaGroups: build.query<PromRulesResponse<GrafanaPromRuleGroupDTO>, GrafanaPromRulesOptions>({
|
||||
query: ({ namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
|
||||
query: ({ folderUid, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
|
||||
url: `api/prometheus/grafana/api/v1/rules`,
|
||||
params: {
|
||||
'file[]': namespace,
|
||||
'group[]': groupName,
|
||||
'rule[]': ruleName,
|
||||
folder_uid: folderUid,
|
||||
rule_group: groupName,
|
||||
rule_name: ruleName,
|
||||
exclude_alerts: excludeAlerts?.toString(),
|
||||
group_limit: groupLimit?.toFixed(0),
|
||||
group_next_token: groupNextToken,
|
||||
|
||||
+1
-1
@@ -25,6 +25,7 @@ import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import { evaluateEveryValidationOptions } from '../../group-details/validation';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import {
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
import { parsePrometheusDuration } from '../../utils/time';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
import { ProvisioningBadge } from '../Provisioning';
|
||||
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
|
||||
|
||||
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
|
||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||
|
||||
+76
-4
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-aler
|
||||
import {
|
||||
LogMessages,
|
||||
logInfo,
|
||||
logWarning,
|
||||
trackAlertRuleFormCancelled,
|
||||
trackAlertRuleFormError,
|
||||
trackAlertRuleFormSaved,
|
||||
@@ -35,7 +36,12 @@ import {
|
||||
trackNewGrafanaAlertRuleFormError,
|
||||
trackNewGrafanaAlertRuleFormSavedSuccess,
|
||||
} from '../../../Analytics';
|
||||
import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
||||
import {
|
||||
GrafanaGroupUpdatedResponse,
|
||||
RulerGroupUpdatedResponse,
|
||||
isGrafanaGroupUpdatedResponse,
|
||||
} from '../../../api/alertRuleModel';
|
||||
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
||||
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
||||
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||
@@ -50,6 +56,7 @@ import {
|
||||
isExpressionQueryInAlert,
|
||||
} from '../../../rule-editor/formProcessing';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { rulesNav } from '../../../utils/navigation';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
SIMPLIFIED_QUERY_EDITOR_KEY,
|
||||
@@ -77,10 +84,12 @@ type Props = {
|
||||
};
|
||||
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
const alertingListViewV2 = shouldUseAlertingListViewV2();
|
||||
|
||||
export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
const { redirectToDetailsPage } = useRedirectToDetailsPage();
|
||||
const [showEditYaml, setShowEditYaml] = useState(false);
|
||||
|
||||
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
|
||||
@@ -169,12 +178,14 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
: getRuleGroupLocationFromFormValues(values);
|
||||
|
||||
const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
|
||||
|
||||
let saveResult: RulerGroupUpdatedResponse;
|
||||
// @TODO move this to a hook too to make sure the logic here is tested for regressions?
|
||||
if (!existing) {
|
||||
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
|
||||
storeInLocalStorageValues(values);
|
||||
// save the rule to the rule group
|
||||
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
|
||||
saveResult = await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
|
||||
// track the new Grafana-managed rule creation in the analytics
|
||||
if (grafanaTypeRule) {
|
||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
@@ -188,7 +199,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
} else {
|
||||
// when updating an existing rule
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
|
||||
await updateRuleInRuleGroup.execute(
|
||||
saveResult = await updateRuleInRuleGroup.execute(
|
||||
ruleGroupIdentifier,
|
||||
ruleIdentifier,
|
||||
ruleDefinition,
|
||||
@@ -198,6 +209,15 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
}
|
||||
|
||||
const { dataSourceName, namespaceName, groupName } = targetRuleGroupIdentifier;
|
||||
|
||||
// V2 list is based on eventually consistent Prometheus API.
|
||||
// When a new rule group is created it takes a while for the new rule group to be reflected in the V2 list.
|
||||
// To avoid user confusion we redirect to the details page which is driven by a strongly consistent Ruler API..
|
||||
if (alertingListViewV2) {
|
||||
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitOnSave) {
|
||||
const returnToUrl = returnTo || getReturnToUrl(targetRuleGroupIdentifier, ruleDefinition);
|
||||
|
||||
@@ -366,6 +386,58 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
);
|
||||
};
|
||||
|
||||
function useRedirectToDetailsPage() {
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const redirectGrafanaRule = useCallback(
|
||||
(saveResult: GrafanaGroupUpdatedResponse) => {
|
||||
const newOrUpdatedRuleUid = saveResult.created?.at(0) || saveResult.updated?.at(0);
|
||||
if (newOrUpdatedRuleUid) {
|
||||
locationService.replace(
|
||||
rulesNav.detailsPageLink('grafana', { uid: newOrUpdatedRuleUid, ruleSourceName: 'grafana' })
|
||||
);
|
||||
} else {
|
||||
notifyApp.error(
|
||||
'Cannot navigate to the new rule details page.',
|
||||
'The rule was created but the UID is missing.'
|
||||
);
|
||||
logWarning('Cannot navigate to the new rule details page. The rule was created but the UID is missing.');
|
||||
}
|
||||
},
|
||||
[notifyApp]
|
||||
);
|
||||
|
||||
const redirectCloudRulerRule = useCallback((rule: RulerRuleDTO, groupId: RuleGroupIdentifier) => {
|
||||
const { dataSourceName, namespaceName, groupName } = groupId;
|
||||
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule);
|
||||
locationService.replace(rulesNav.detailsPageLink(updatedRuleIdentifier.ruleSourceName, updatedRuleIdentifier));
|
||||
}, []);
|
||||
|
||||
const redirectToDetailsPage = useCallback(
|
||||
(
|
||||
rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO,
|
||||
groupId: RuleGroupIdentifier,
|
||||
saveResult: RulerGroupUpdatedResponse
|
||||
) => {
|
||||
if (isGrafanaGroupUpdatedResponse(saveResult)) {
|
||||
redirectGrafanaRule(saveResult);
|
||||
return;
|
||||
} else if (rulerRuleType.dataSource.rule(rule)) {
|
||||
redirectCloudRulerRule(rule, groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
logWarning(
|
||||
'Cannot navigate to the new rule details page. The response is not a GrafanaGroupUpdatedResponse and ruleDefinition is not a Cloud Ruler rule.',
|
||||
{ ruleFormType: rulerRuleType.dataSource.rule(rule) ? 'datasource' : 'grafana' }
|
||||
);
|
||||
},
|
||||
[redirectGrafanaRule, redirectCloudRulerRule]
|
||||
);
|
||||
|
||||
return { redirectToDetailsPage };
|
||||
}
|
||||
|
||||
function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) {
|
||||
const { dataSourceName, namespaceName, groupName } = groupId;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRelativeUrl } from '../../utils/url';
|
||||
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
|
||||
|
||||
interface Props {
|
||||
promRule: Rule;
|
||||
promRule?: Rule;
|
||||
rulerRule?: RulerRuleDTO;
|
||||
identifier: RuleIdentifier;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { chain, isEmpty, truncate } from 'lodash';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { NavModelItem, UrlQueryValue } from '@grafana/data';
|
||||
import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
Alert,
|
||||
LinkButton,
|
||||
LoadingBar,
|
||||
Stack,
|
||||
TabContent,
|
||||
Text,
|
||||
TextLink,
|
||||
useStyles2,
|
||||
withErrorBoundary,
|
||||
} from '@grafana/ui';
|
||||
import { PageInfoItem } from 'app/core/components/Page/types';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
|
||||
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
|
||||
import { AlertInstanceTotalState, CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import {
|
||||
AlertInstanceTotalState,
|
||||
CombinedRule,
|
||||
RuleGroupIdentifierV2,
|
||||
RuleHealth,
|
||||
RuleIdentifier,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { logError } from '../../Analytics';
|
||||
import { defaultPageNav } from '../../RuleViewer';
|
||||
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
||||
import { usePrometheusCreationConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
|
||||
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
||||
import { isError, useAsync } from '../../hooks/useAsync';
|
||||
import { useRuleLocation } from '../../hooks/useCombinedRule';
|
||||
import { useHasRulerV2 } from '../../hooks/useHasRuler';
|
||||
import { useRuleGroupConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
|
||||
import { useReturnTo } from '../../hooks/useReturnTo';
|
||||
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { ruleIdentifierToRuleSourceIdentifier } from '../../utils/datasource';
|
||||
import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc';
|
||||
import { createListFilterLink } from '../../utils/navigation';
|
||||
import {
|
||||
@@ -57,6 +78,9 @@ export enum ActiveTab {
|
||||
}
|
||||
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
const alertingListViewV2 = shouldUseAlertingListViewV2();
|
||||
|
||||
const shouldUseConsistencyCheck = prometheusRulesPrimary || alertingListViewV2;
|
||||
|
||||
const RuleViewer = () => {
|
||||
const { rule, identifier } = useAlertRule();
|
||||
@@ -118,7 +142,7 @@ const RuleViewer = () => {
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{prometheusRulesPrimary && <PrometheusConsistencyCheck ruleIdentifier={identifier} />}
|
||||
{shouldUseConsistencyCheck && <PrometheusConsistencyCheck ruleIdentifier={identifier} />}
|
||||
<Stack direction="column" gap={2}>
|
||||
{/* tabs and tab content */}
|
||||
<TabContent>
|
||||
@@ -267,41 +291,64 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi
|
||||
);
|
||||
};
|
||||
|
||||
interface PrometheusConsistencyCheckProps {
|
||||
ruleIdentifier: RuleIdentifier;
|
||||
}
|
||||
/**
|
||||
* This component displays an Alert warning component if discovers inconsistencies between Prometheus and Ruler rules
|
||||
* It will show loading indicator until the Prometheus and Ruler rule is consistent
|
||||
* It will not show the warning if the rule is Grafana managed
|
||||
*/
|
||||
function PrometheusConsistencyCheck({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) {
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const { isConsistent, error } = usePrometheusCreationConsistencyCheck(ruleIdentifier);
|
||||
const PrometheusConsistencyCheck = withErrorBoundary(
|
||||
({ ruleIdentifier }: PrometheusConsistencyCheckProps) => {
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const { hasRuler } = useHasRulerV2(ruleIdentifierToRuleSourceIdentifier(ruleIdentifier));
|
||||
const { result: ruleLocation } = useRuleLocation(ruleIdentifier);
|
||||
|
||||
const { waitForGroupConsistency, groupConsistent } = useRuleGroupConsistencyCheck();
|
||||
|
||||
const [waitAction, waitState] = useAsync((groupIdentifier: RuleGroupIdentifierV2) => {
|
||||
return waitForGroupConsistency(groupIdentifier);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ruleLocation && hasRuler) {
|
||||
waitAction.execute(ruleLocation.groupIdentifier);
|
||||
}
|
||||
}, [ruleLocation, hasRuler, waitAction]);
|
||||
|
||||
if (isError(waitState)) {
|
||||
return (
|
||||
<Alert title="Unable to check the rule status" bottomSpacing={0} topSpacing={2}>
|
||||
{stringifyErrorLike(waitState.error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// If groupConsistent is undefined, it means that the rule is still being checked and we don't know if it's consistent or not
|
||||
// To prevent the inconsistency banner from blinking, we only show it if groupConsistent is false
|
||||
if (groupConsistent === false) {
|
||||
return (
|
||||
<Stack direction="column" gap={0} ref={ref}>
|
||||
<LoadingBar width={width} />
|
||||
<Alert
|
||||
title={t('alerting.rule-viewer.prometheus-consistency-check.alert-title', 'Update in progress')}
|
||||
severity="info"
|
||||
>
|
||||
<Trans i18nKey="alerting.rule-viewer.prometheus-consistency-check.alert-message">
|
||||
Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list
|
||||
view.
|
||||
</Trans>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ errorLogger: logError }
|
||||
);
|
||||
|
||||
export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err';
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { HttpResponse } from 'msw';
|
||||
import { render } from 'test/test-utils';
|
||||
import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector';
|
||||
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import server, { setupMswServer } from '../../mockApi';
|
||||
import { mimirDataSource } from '../../mocks/server/configure';
|
||||
import { alertingFactory } from '../../mocks/server/db';
|
||||
import { rulerRuleGroupHandler as grafanaRulerRuleGroupHandler } from '../../mocks/server/handlers/grafanaRuler';
|
||||
import { rulerRuleGroupHandler } from '../../mocks/server/handlers/mimirRuler';
|
||||
import { grantPermissionsHelper } from '../../test/test-utils';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { EditRuleGroupModal } from './EditRuleGroupModal';
|
||||
|
||||
const ui = {
|
||||
input: {
|
||||
namespace: byLabelText(/^Folder|^Namespace/, { exact: true }),
|
||||
group: byLabelText(/Evaluation group/),
|
||||
interval: byLabelText(/Evaluation interval/),
|
||||
},
|
||||
folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role
|
||||
table: byTestId('dynamic-table'),
|
||||
tableRows: byTestId('row'),
|
||||
noRulesText: byText('This group does not contain alert rules.'),
|
||||
};
|
||||
|
||||
const noop = () => jest.fn();
|
||||
setupMswServer();
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
useReturnToPrevious: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('EditGroupModal component on cloud alert rules', () => {
|
||||
it('Should disable all inputs but interval when intervalEditOnly is set', async () => {
|
||||
const { rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
const rulerGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: rulerConfig.dataSourceName,
|
||||
groupName: 'default-group',
|
||||
namespaceName: 'my-namespace',
|
||||
};
|
||||
|
||||
render(
|
||||
<EditRuleGroupModal
|
||||
ruleGroupIdentifier={rulerGroupIdentifier}
|
||||
intervalEditOnly
|
||||
onClose={noop}
|
||||
rulerConfig={rulerConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly');
|
||||
expect(ui.input.group.get()).toHaveAttribute('readonly');
|
||||
expect(ui.input.interval.get()).not.toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('Should show alert table in case of having some non-recording rules in the group', async () => {
|
||||
const { dataSource, rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: dataSource.name,
|
||||
groupName: group.name,
|
||||
namespaceName: 'ns1',
|
||||
};
|
||||
|
||||
render(<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={rulerConfig} onClose={noop} />);
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue('ns1');
|
||||
expect(ui.input.namespace.get()).not.toHaveAttribute('readonly');
|
||||
expect(ui.input.group.get()).toHaveValue(group.name);
|
||||
|
||||
// @ts-ignore
|
||||
const ruleName = group.rules.at(0).alert;
|
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(1); // Only one rule is non-recording
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(ruleName);
|
||||
});
|
||||
|
||||
it('Should not show alert table in case of having exclusively recording rules in the group', async () => {
|
||||
const { dataSource, rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.recordingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
// @TODO need to simplify this a bit I think
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: dataSource.name,
|
||||
groupName: group.name,
|
||||
namespaceName: 'ns1',
|
||||
};
|
||||
|
||||
render(<EditRuleGroupModal rulerConfig={rulerConfig} ruleGroupIdentifier={ruleGroupIdentifier} onClose={noop} />);
|
||||
expect(ui.table.query()).not.toBeInTheDocument();
|
||||
expect(await ui.noRulesText.find()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditGroupModal component on grafana-managed alert rules', () => {
|
||||
// @TODO simplify folder stuff, should also have a higher-level function to set these up
|
||||
const folder = alertingFactory.folder.build();
|
||||
const NAMESPACE_UID = folder.uid;
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.alertingRule.build()],
|
||||
});
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
groupName: group.name,
|
||||
namespaceName: NAMESPACE_UID,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
grantPermissionsHelper([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleUpdate,
|
||||
]);
|
||||
|
||||
server.use(
|
||||
grafanaRulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const renderWithGrafanaGroup = () =>
|
||||
render(
|
||||
<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={GRAFANA_RULER_CONFIG} onClose={noop} />
|
||||
);
|
||||
|
||||
it('Should show alert table', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID);
|
||||
expect(ui.input.group.get()).toHaveValue(group.name);
|
||||
expect(ui.input.interval.get()).toHaveValue(group.interval);
|
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(2);
|
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(group.rules.at(0).alert);
|
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(1)).toHaveTextContent(group.rules.at(1).alert);
|
||||
});
|
||||
|
||||
it('Should have folder input in readonly mode', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('Should not display folder link if no folderUrl provided', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID);
|
||||
expect(ui.folderLink.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { compact } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { FieldValues, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
Label,
|
||||
LinkButton,
|
||||
LoadingPlaceholder,
|
||||
Modal,
|
||||
Stack,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { RuleGroupIdentifier, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import {
|
||||
useMoveRuleGroup,
|
||||
useRenameRuleGroup,
|
||||
useUpdateRuleGroupConfiguration,
|
||||
} from '../../hooks/ruleGroup/useUpdateRuleGroup';
|
||||
import { anyOfRequestState } from '../../hooks/useAsync';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
|
||||
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { AlertInfo, getAlertInfo, rulerRuleType } from '../../utils/rules';
|
||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
||||
import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick';
|
||||
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
|
||||
|
||||
const useRuleGroupDefinition = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery;
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
function ForBadge({ message, error }: { message: string; error?: boolean }) {
|
||||
if (error) {
|
||||
return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />;
|
||||
} else {
|
||||
return <Badge color="orange" icon="exclamation-triangle" text={'Unknown'} tooltip={message} />;
|
||||
}
|
||||
}
|
||||
|
||||
const isValidEvaluation = (evaluation: string) => {
|
||||
try {
|
||||
const duration = parsePrometheusDuration(evaluation);
|
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type AlertsWithForTableColumnProps = DynamicTableColumnProps<AlertInfo>;
|
||||
type AlertsWithForTableProps = DynamicTableItemProps<AlertInfo>;
|
||||
|
||||
export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
const currentInterval = watch('groupInterval');
|
||||
const unknownCurrentInterval = !Boolean(currentInterval);
|
||||
|
||||
const rows: AlertsWithForTableProps[] = rulesWithoutRecordingRules
|
||||
.slice()
|
||||
.map((rule: RulerRuleDTO, index) => ({
|
||||
id: index,
|
||||
data: getAlertInfo(rule, currentInterval),
|
||||
}))
|
||||
.sort(
|
||||
(alert1, alert2) =>
|
||||
safeParsePrometheusDuration(alert1.data.forDuration ?? '') -
|
||||
safeParsePrometheusDuration(alert2.data.forDuration ?? '')
|
||||
);
|
||||
|
||||
const columns: AlertsWithForTableColumnProps[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'alertName',
|
||||
label: 'Alert',
|
||||
renderCell: ({ data: { alertName } }) => {
|
||||
return <>{alertName}</>;
|
||||
},
|
||||
size: '330px',
|
||||
},
|
||||
{
|
||||
id: 'for',
|
||||
label: 'Pending period',
|
||||
renderCell: ({ data: { forDuration } }) => {
|
||||
return <>{forDuration}</>;
|
||||
},
|
||||
size: 0.5,
|
||||
},
|
||||
{
|
||||
id: 'numberEvaluations',
|
||||
label: '#Eval',
|
||||
renderCell: ({ data: { evaluationsToFire: numberEvaluations } }) => {
|
||||
if (unknownCurrentInterval) {
|
||||
return <ForBadge message="#Evaluations not available." />;
|
||||
} else {
|
||||
if (!isValidEvaluation(currentInterval)) {
|
||||
return <ForBadge message={'Invalid evaluation interval format'} error />;
|
||||
}
|
||||
if (numberEvaluations === 0) {
|
||||
return (
|
||||
<ForBadge message="Invalid 'For' value: it should be greater or equal to evaluation interval." error />
|
||||
);
|
||||
} else {
|
||||
return <>{numberEvaluations}</>;
|
||||
}
|
||||
}
|
||||
},
|
||||
size: 0.4,
|
||||
},
|
||||
];
|
||||
}, [currentInterval, unknownCurrentInterval]);
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DynamicTable items={rows} cols={columns} pagination={{ itemsPerPage: ITEMS_PER_PAGE }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormValues {
|
||||
namespaceName: string;
|
||||
groupName: string;
|
||||
groupInterval: string;
|
||||
}
|
||||
|
||||
export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: RulerRuleDTO[]): RegisterOptions<T> => ({
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required.',
|
||||
},
|
||||
validate: (evaluateEvery: string) => {
|
||||
try {
|
||||
const duration = parsePrometheusDuration(evaluateEvery);
|
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
||||
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) {
|
||||
return true;
|
||||
} else {
|
||||
const rulePendingPeriods = rules.map((rule) => {
|
||||
const { forDuration } = getAlertInfo(rule, evaluateEvery);
|
||||
return forDuration ? safeParsePrometheusDuration(forDuration) : null;
|
||||
});
|
||||
// 0 is a special case which disables the pending period at all
|
||||
const smallestPendingPeriod = Math.min(
|
||||
...rulePendingPeriods.filter((period): period is number => period !== null && period !== 0)
|
||||
);
|
||||
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(smallestPendingPeriod)}".`;
|
||||
}
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Failed to parse duration';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export interface ModalProps {
|
||||
ruleGroupIdentifier: RuleGroupIdentifier;
|
||||
folderTitle?: string;
|
||||
rulerConfig: RulerDataSourceConfig;
|
||||
onClose: (saved?: boolean) => void;
|
||||
intervalEditOnly?: boolean;
|
||||
folderUrl?: string;
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
export interface ModalFormProps {
|
||||
ruleGroupIdentifier: RuleGroupIdentifier;
|
||||
folderTitle?: string; // used to display the GMA folder title
|
||||
ruleGroup: RulerRuleGroupDTO;
|
||||
onClose: (saved?: boolean) => void;
|
||||
intervalEditOnly?: boolean;
|
||||
folderUrl?: string;
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
// this component just wraps the modal with some loading state for grabbing rules and such
|
||||
export function EditRuleGroupModal(props: ModalProps) {
|
||||
const { ruleGroupIdentifier, rulerConfig, intervalEditOnly, onClose } = props;
|
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName;
|
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const modalTitle =
|
||||
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const {
|
||||
data: ruleGroup,
|
||||
error,
|
||||
isLoading,
|
||||
} = useRuleGroupDefinition({
|
||||
group: ruleGroupIdentifier.groupName,
|
||||
namespace: ruleGroupIdentifier.namespaceName,
|
||||
rulerConfig,
|
||||
});
|
||||
|
||||
const loadingText = t('alerting.common.loading', 'Loading...');
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
|
||||
{isLoading && <LoadingPlaceholder text={loadingText} />}
|
||||
{error ? stringifyErrorLike(error) : null}
|
||||
{ruleGroup && <EditRuleGroupModalForm {...props} ruleGroup={ruleGroup} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditRuleGroupModalForm(props: ModalFormProps): React.ReactElement {
|
||||
const { ruleGroup, ruleGroupIdentifier, folderTitle, onClose, intervalEditOnly } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
/**
|
||||
* This modal can take 3 different actions, depending on what fields were updated.
|
||||
*
|
||||
* 1. update the rule group details without renaming either the namespace or group
|
||||
* 2. rename the rule group, but keeping it in the same namespace
|
||||
* 3. move the rule group to a new namespace, optionally with a different group name
|
||||
*/
|
||||
const [updateRuleGroup, updateRuleGroupState] = useUpdateRuleGroupConfiguration();
|
||||
const [renameRuleGroup, renameRuleGroupState] = useRenameRuleGroup();
|
||||
const [moveRuleGroup, moveRuleGroupState] = useMoveRuleGroup();
|
||||
|
||||
const { loading, error } = anyOfRequestState(updateRuleGroupState, moveRuleGroupState, renameRuleGroupState);
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormValues => ({
|
||||
namespaceName: ruleGroupIdentifier.namespaceName,
|
||||
groupName: ruleGroupIdentifier.groupName,
|
||||
groupInterval: ruleGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
}),
|
||||
[ruleGroup?.interval, ruleGroupIdentifier.groupName, ruleGroupIdentifier.namespaceName]
|
||||
);
|
||||
|
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName;
|
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
|
||||
const updatedNamespaceName = values.namespaceName;
|
||||
const updatedGroupName = values.groupName;
|
||||
const updatedInterval = values.groupInterval;
|
||||
|
||||
// GMA alert rules cannot be moved to another folder, we currently do not support it but it should be doable (with caveats).
|
||||
const shouldMove = isGrafanaManagedGroup ? false : updatedNamespaceName !== ruleGroupIdentifier.namespaceName;
|
||||
const shouldRename = updatedGroupName !== ruleGroupIdentifier.groupName;
|
||||
|
||||
try {
|
||||
if (shouldMove) {
|
||||
await moveRuleGroup.execute(ruleGroupIdentifier, updatedNamespaceName, updatedGroupName, updatedInterval);
|
||||
} else if (shouldRename) {
|
||||
await renameRuleGroup.execute(ruleGroupIdentifier, updatedGroupName, updatedInterval);
|
||||
} else {
|
||||
await updateRuleGroup.execute(ruleGroupIdentifier, updatedInterval);
|
||||
}
|
||||
onClose(true);
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName }));
|
||||
} catch (_error) {} // React hook form will handle errors
|
||||
};
|
||||
|
||||
const formAPI = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues,
|
||||
shouldFocusError: true,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
formState: { isDirty, errors, isValid },
|
||||
setValue,
|
||||
getValues,
|
||||
} = formAPI;
|
||||
|
||||
const onInvalid = () => {
|
||||
notifyApp.error('There are errors in the form. Correct the errors and retry.');
|
||||
};
|
||||
|
||||
const rulesWithoutRecordingRules = compact(ruleGroup?.rules.filter((rule) => !rulerRuleType.any.recordingRule(rule)));
|
||||
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
|
||||
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}>
|
||||
{!props.hideFolder && (
|
||||
<Stack gap={1} alignItems={'center'}>
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label={
|
||||
<Label
|
||||
htmlFor="namespaceName"
|
||||
description={
|
||||
!isGrafanaManagedGroup &&
|
||||
'Change the current namespace name. Moving groups between namespaces is not supported'
|
||||
}
|
||||
>
|
||||
{nameSpaceLabel}
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.namespaceName) ? true : undefined}
|
||||
error={errors.namespaceName?.message}
|
||||
>
|
||||
<Input
|
||||
id="namespaceName"
|
||||
readOnly={intervalEditOnly || isGrafanaManagedGroup}
|
||||
value={folderTitle}
|
||||
{...register('namespaceName', {
|
||||
required: 'Namespace name is required.',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
{isGrafanaManagedGroup && props.folderUrl && (
|
||||
<LinkButton
|
||||
href={props.folderUrl}
|
||||
title="Go to folder"
|
||||
variant="secondary"
|
||||
icon="folder-open"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="groupName" description="A group evaluates all its rules over the same evaluation interval.">
|
||||
Evaluation group
|
||||
</Label>
|
||||
}
|
||||
invalid={!!errors.groupName}
|
||||
error={errors.groupName?.message}
|
||||
>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
id="groupName"
|
||||
readOnly={intervalEditOnly}
|
||||
{...register('groupName', {
|
||||
required: 'Evaluation group name is required.',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<Label
|
||||
htmlFor="groupInterval"
|
||||
description="How often is the rule evaluated. Applies to every rule within the group."
|
||||
>
|
||||
<Stack gap={0.5}>Evaluation interval</Stack>
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.groupInterval) ? true : undefined}
|
||||
error={errors.groupInterval?.message}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<Input
|
||||
id="groupInterval"
|
||||
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
|
||||
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
|
||||
/>
|
||||
<EvaluationGroupQuickPick
|
||||
currentInterval={getValues('groupInterval')}
|
||||
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
|
||||
{/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */}
|
||||
{isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
|
||||
<EvaluationIntervalLimitExceeded />
|
||||
)}
|
||||
|
||||
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
|
||||
{hasSomeNoRecordingRules && (
|
||||
<>
|
||||
<div>List of rules that belong to this group</div>
|
||||
<div className={styles.evalRequiredLabel}>
|
||||
#Eval column represents the number of evaluations needed before alert starts firing.
|
||||
</div>
|
||||
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
|
||||
</>
|
||||
)}
|
||||
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>}
|
||||
<div className={styles.modalButtons}>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" type="button" disabled={loading} onClick={() => onClose(false)} fill="outline">
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isDirty || !isValid || loading}>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modal: css({
|
||||
maxWidth: '560px',
|
||||
}),
|
||||
modalButtons: css({
|
||||
top: '-24px',
|
||||
position: 'relative',
|
||||
}),
|
||||
formInput: css({
|
||||
flex: 1,
|
||||
}),
|
||||
tableWrapper: css({
|
||||
marginTop: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
height: '100%',
|
||||
}),
|
||||
evalRequiredLabel: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
}),
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
DraggableProvided,
|
||||
DropResult,
|
||||
Droppable,
|
||||
DroppableProvided,
|
||||
} from '@hello-pangea/dnd';
|
||||
import cx from 'classnames';
|
||||
import { produce } from 'immer';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import {
|
||||
CombinedRuleGroup,
|
||||
CombinedRuleNamespace,
|
||||
RuleGroupIdentifier,
|
||||
RulerDataSourceConfig,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup';
|
||||
import { isLoading } from '../../hooks/useAsync';
|
||||
import { SwapOperation, swapItems } from '../../reducers/ruler/ruleGroups';
|
||||
import { fetchRulerRulesAction } from '../../state/actions';
|
||||
import { isCloudRulesSource } from '../../utils/datasource';
|
||||
import { hashRulerRule } from '../../utils/rule-id';
|
||||
import { getRuleName, rulerRuleType, rulesSourceToDataSourceName } from '../../utils/rules';
|
||||
|
||||
interface ModalProps {
|
||||
namespace: CombinedRuleNamespace;
|
||||
group: CombinedRuleGroup;
|
||||
onClose: () => void;
|
||||
folderUid?: string;
|
||||
rulerConfig: RulerDataSourceConfig;
|
||||
}
|
||||
|
||||
type RulerRuleWithUID = { uid: string } & RulerRuleDTO;
|
||||
|
||||
export const ReorderCloudGroupModal = (props: ModalProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { group, namespace, onClose, folderUid } = props;
|
||||
const [operations, setOperations] = useState<Array<[number, number]>>([]);
|
||||
|
||||
const [reorderRulesInGroup, reorderState] = useReorderRuleForRuleGroup();
|
||||
const isUpdating = isLoading(reorderState);
|
||||
|
||||
// The list of rules might have been filtered before we get to this reordering modal
|
||||
// We need to grab the full (unfiltered) list
|
||||
const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery(
|
||||
{
|
||||
rulerConfig: props.rulerConfig,
|
||||
namespace: folderUid ?? namespace.name,
|
||||
group: group.name,
|
||||
},
|
||||
{ refetchOnMountOrArgChange: true }
|
||||
);
|
||||
|
||||
const [rulesList, setRulesList] = useState<RulerRuleDTO[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ruleGroup) {
|
||||
setRulesList(ruleGroup?.rules);
|
||||
}
|
||||
}, [ruleGroup]);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
// check for no-ops so we don't update the group unless we have changes
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swapOperation: SwapOperation = [result.source.index, result.destination.index];
|
||||
|
||||
// add old index and new index to the modifications object
|
||||
setOperations(
|
||||
produce(operations, (draft) => {
|
||||
draft.push(swapOperation);
|
||||
})
|
||||
);
|
||||
|
||||
// re-order the rules list for the UI rendering
|
||||
const newOrderedRules = produce(rulesList, (draft) => {
|
||||
swapItems(draft, swapOperation);
|
||||
});
|
||||
setRulesList(newOrderedRules);
|
||||
},
|
||||
[rulesList, operations]
|
||||
);
|
||||
|
||||
const updateRulesOrder = useCallback(async () => {
|
||||
const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource);
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName,
|
||||
groupName: group.name,
|
||||
namespaceName: folderUid ?? namespace.name,
|
||||
};
|
||||
|
||||
await reorderRulesInGroup.execute(ruleGroupIdentifier, operations);
|
||||
// TODO: Remove once RTKQ is more prevalently used
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName }));
|
||||
onClose();
|
||||
}, [namespace.rulesSource, namespace.name, group.name, folderUid, reorderRulesInGroup, operations, onClose]);
|
||||
|
||||
// assign unique but stable identifiers to each (alerting / recording) rule
|
||||
const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({
|
||||
...rulerRule,
|
||||
uid: hashRulerRule(rulerRule),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={true}
|
||||
title={<ModalHeader namespace={namespace} group={group} />}
|
||||
onDismiss={onClose}
|
||||
onClickBackdrop={onClose}
|
||||
>
|
||||
{loadingRules && 'Loading...'}
|
||||
{rulesWithUID.length > 0 && (
|
||||
<>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="alert-list"
|
||||
mode="standard"
|
||||
renderClone={(provided, _snapshot, rubric) => (
|
||||
<ListItem provided={provided} rule={rulesWithUID[rubric.source.index]} isClone />
|
||||
)}
|
||||
>
|
||||
{(droppableProvided: DroppableProvided) => (
|
||||
<div
|
||||
ref={droppableProvided.innerRef}
|
||||
className={cx(styles.listContainer, isUpdating && styles.disabled)}
|
||||
{...droppableProvided.droppableProps}
|
||||
>
|
||||
{rulesWithUID.map((rule, index) => (
|
||||
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={isUpdating}>
|
||||
{(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />}
|
||||
</Draggable>
|
||||
))}
|
||||
{droppableProvided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" fill="outline" onClick={onClose}>
|
||||
<Trans i18nKey={'common.cancel'}>Cancel</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => updateRulesOrder()} disabled={isUpdating}>
|
||||
<Trans i18nKey={'common.save'}>Save</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
provided: DraggableProvided;
|
||||
rule: RulerRuleDTO;
|
||||
isClone?: boolean;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// @TODO does this work with Grafana-managed recording rules too? Double check that.
|
||||
return (
|
||||
<div
|
||||
data-testid="reorder-alert-rule"
|
||||
className={cx(styles.listItem, isClone && 'isClone', isDragging && 'isDragging')}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<div className={styles.listItemName}>
|
||||
{getRuleName(rule)}
|
||||
{rulerRuleType.any.recordingRule(rule) && (
|
||||
<>
|
||||
{' '}
|
||||
<Badge text="Recording" color="purple" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{rulerRuleType.dataSource.alertingRule(rule) && <div className={styles.listItemName}>{rule.alert}</div>}
|
||||
<Icon name="draggabledots" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
namespace: CombinedRuleNamespace;
|
||||
group: CombinedRuleGroup;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ namespace, group }: ModalHeaderProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<Icon name="folder" />
|
||||
{isCloudRulesSource(namespace.rulesSource) && (
|
||||
<Tooltip content={namespace.rulesSource.name} placement="top">
|
||||
<img
|
||||
alt={namespace.rulesSource.meta.name}
|
||||
className={styles.dataSourceIcon}
|
||||
src={namespace.rulesSource.meta.info.logos.small}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span>{namespace.name}</span>
|
||||
<Icon name="angle-right" />
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modal: css({
|
||||
maxWidth: '640px',
|
||||
maxHeight: '80%',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
listItem: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
gap: theme.spacing(),
|
||||
|
||||
background: theme.colors.background.primary,
|
||||
color: theme.colors.text.secondary,
|
||||
|
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
|
||||
'&.isClone': {
|
||||
border: `solid 1px ${theme.colors.primary.shade}`,
|
||||
},
|
||||
}),
|
||||
listContainer: css({
|
||||
userSelect: 'none',
|
||||
border: `solid 1px ${theme.colors.border.medium}`,
|
||||
}),
|
||||
disabled: css({
|
||||
opacity: '0.5',
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
listItemName: css({
|
||||
flex: 1,
|
||||
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
header: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
dataSourceIcon: css({
|
||||
width: theme.spacing(2),
|
||||
height: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
|
||||
);
|
||||
}
|
||||
|
||||
if (!rule.promRule) {
|
||||
if (!rule.promRule && !rule.rulerRule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
|
||||
import * as analytics from '../../Analytics';
|
||||
import { mockCombinedRule, mockDataSource } from '../../mocks';
|
||||
import { setupMswServer } from '../../mockApi';
|
||||
import { mockCombinedRule } from '../../mocks';
|
||||
import { mimirDataSource } from '../../mocks/server/configure';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { RuleListGroupView } from './RuleListGroupView';
|
||||
@@ -25,6 +27,9 @@ setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
setupMswServer();
|
||||
const mimirDs = mimirDataSource();
|
||||
|
||||
describe('RuleListGroupView', () => {
|
||||
describe('RBAC', () => {
|
||||
it('Should display Grafana rules when the user has the alert rule read permission', async () => {
|
||||
@@ -119,7 +124,7 @@ function getGrafanaNamespace(): CombinedRuleNamespace {
|
||||
function getCloudNamespace(): CombinedRuleNamespace {
|
||||
return {
|
||||
name: 'Cloud Test Namespace',
|
||||
rulesSource: mockDataSource(),
|
||||
rulesSource: mimirDs.dataSource,
|
||||
groups: [
|
||||
{
|
||||
name: 'Prom group',
|
||||
|
||||
@@ -4,6 +4,8 @@ import { isAdmin } from './utils/misc';
|
||||
|
||||
export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false;
|
||||
|
||||
export const shouldUseAlertingListViewV2 = () => config.featureToggles.alertingListViewV2 ?? false;
|
||||
|
||||
export const useGrafanaManagedRecordingRulesSupport = () =>
|
||||
config.unifiedAlerting.recordingRulesEnabled && config.featureToggles.grafanaManagedRecordingRules;
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import { EvaluationGroupQuickPick } from '../components/rule-editor/EvaluationGroupQuickPick';
|
||||
import { evaluateEveryValidationOptions } from '../components/rules/EditRuleGroupModal';
|
||||
import { useDeleteRuleGroup } from '../hooks/ruleGroup/useDeleteRuleGroup';
|
||||
import { UpdateGroupDelta, useUpdateRuleGroup } from '../hooks/ruleGroup/useUpdateRuleGroup';
|
||||
import { isLoading, useAsync } from '../hooks/useAsync';
|
||||
@@ -43,6 +42,7 @@ import { stringifyErrorLike } from '../utils/misc';
|
||||
import { alertListPageLink, createListFilterLink, groups } from '../utils/navigation';
|
||||
|
||||
import { DraggableRulesTable } from './components/DraggableRulesTable';
|
||||
import { evaluateEveryValidationOptions } from './validation';
|
||||
|
||||
type GroupEditPageRouteParams = {
|
||||
dataSourceUid?: string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { FieldValues, RegisterOptions } from 'react-hook-form';
|
||||
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { MIN_TIME_RANGE_STEP_S } from '../components/rule-editor/GrafanaEvaluationBehavior';
|
||||
import { rulesInSameGroupHaveInvalidFor } from '../state/actions';
|
||||
import { getAlertInfo } from '../utils/rules';
|
||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../utils/time';
|
||||
|
||||
export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: RulerRuleDTO[]): RegisterOptions<T> => ({
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required.',
|
||||
},
|
||||
validate: (evaluateEvery: string) => {
|
||||
try {
|
||||
const duration = parsePrometheusDuration(evaluateEvery);
|
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
||||
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) {
|
||||
return true;
|
||||
} else {
|
||||
const rulePendingPeriods = rules.map((rule) => {
|
||||
const { forDuration } = getAlertInfo(rule, evaluateEvery);
|
||||
return forDuration ? safeParsePrometheusDuration(forDuration) : null;
|
||||
});
|
||||
// 0 is a special case which disables the pending period at all
|
||||
const smallestPendingPeriod = Math.min(
|
||||
...rulePendingPeriods.filter((period): period is number => period !== null && period !== 0)
|
||||
);
|
||||
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(smallestPendingPeriod)}".`;
|
||||
}
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Failed to parse duration';
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -3,12 +3,19 @@ import { useEffect, useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { isGrafanaRulesSource } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { CombinedRule, RuleIdentifier, RuleWithLocation, RulesSource } from 'app/types/unified-alerting';
|
||||
import {
|
||||
CombinedRule,
|
||||
RuleGroupIdentifierV2,
|
||||
RuleIdentifier,
|
||||
RuleWithLocation,
|
||||
RulesSource,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getDataSourceByName } from '../utils/datasource';
|
||||
import { groupIdentifier } from '../utils/groupIdentifier';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
|
||||
|
||||
@@ -170,6 +177,7 @@ export interface RuleLocation {
|
||||
namespace: string;
|
||||
group: string;
|
||||
ruleName: string;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
}
|
||||
|
||||
export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocation> {
|
||||
@@ -189,15 +197,20 @@ export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<Ru
|
||||
|
||||
return useMemo(() => {
|
||||
if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
|
||||
return {
|
||||
result: {
|
||||
datasource: ruleIdentifier.ruleSourceName,
|
||||
namespace: ruleIdentifier.namespace,
|
||||
group: ruleIdentifier.groupName,
|
||||
ruleName: ruleIdentifier.ruleName,
|
||||
},
|
||||
loading: false,
|
||||
};
|
||||
try {
|
||||
return {
|
||||
result: {
|
||||
datasource: ruleIdentifier.ruleSourceName,
|
||||
namespace: ruleIdentifier.namespace,
|
||||
group: ruleIdentifier.groupName,
|
||||
ruleName: ruleIdentifier.ruleName,
|
||||
groupIdentifier: groupIdentifier.fromRuleIdentifier(ruleIdentifier),
|
||||
} satisfies RuleLocation,
|
||||
loading: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return { loading: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||
@@ -215,7 +228,12 @@ export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<Ru
|
||||
namespace: currentData.grafana_alert.namespace_uid,
|
||||
group: currentData.grafana_alert.rule_group,
|
||||
ruleName: currentData.grafana_alert.title,
|
||||
},
|
||||
groupIdentifier: {
|
||||
namespace: { uid: currentData.grafana_alert.namespace_uid },
|
||||
groupName: currentData.grafana_alert.rule_group,
|
||||
groupOrigin: 'grafana',
|
||||
},
|
||||
} satisfies RuleLocation,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RulesSource } from 'app/types/unified-alerting';
|
||||
import { RulesSource, RulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getRulesSourceName } from '../utils/datasource';
|
||||
@@ -6,6 +6,7 @@ import { getRulesSourceName } from '../utils/datasource';
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
// datasource has ruler if the discovery api returns a rulerConfig
|
||||
/** @deprecated use useHasRulerV2 instead */
|
||||
export function useHasRuler(rulesSource: RulesSource) {
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
@@ -14,3 +15,10 @@ export function useHasRuler(rulesSource: RulesSource) {
|
||||
|
||||
return { hasRuler, rulerConfig: dsFeatures?.rulerConfig };
|
||||
}
|
||||
|
||||
export function useHasRulerV2(rulesSource: RulesSourceIdentifier) {
|
||||
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
|
||||
const hasRuler = Boolean(dsFeatures?.rulerConfig);
|
||||
|
||||
return { hasRuler, rulerConfig: dsFeatures?.rulerConfig };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zip } from 'lodash';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
CloudRuleIdentifier,
|
||||
@@ -76,6 +76,8 @@ export function useRuleGroupIsInSync() {
|
||||
ruleSourceName: dsFeatures.name,
|
||||
namespace: namespace,
|
||||
groupName: ruleIdentifier.groupName,
|
||||
limitAlerts: 0,
|
||||
excludeAlerts: true,
|
||||
};
|
||||
const rulerParams: Parameters<typeof fetchRuleGroup>[0] = {
|
||||
namespace,
|
||||
@@ -151,8 +153,9 @@ export function useRuleGroupIsInSync() {
|
||||
|
||||
export function useRuleGroupConsistencyCheck() {
|
||||
const { isGroupInSync } = useRuleGroupIsInSync();
|
||||
const [groupConsistent, setGroupConsistent] = useState<boolean | undefined>();
|
||||
|
||||
const consistencyInterval = useRef<number | undefined>();
|
||||
const consistencyInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -162,7 +165,7 @@ export function useRuleGroupConsistencyCheck() {
|
||||
|
||||
const clearConsistencyInterval = () => {
|
||||
if (consistencyInterval.current) {
|
||||
clearInterval(consistencyInterval.current);
|
||||
clearTimeout(consistencyInterval.current);
|
||||
consistencyInterval.current = undefined;
|
||||
}
|
||||
};
|
||||
@@ -186,37 +189,46 @@ export function useRuleGroupConsistencyCheck() {
|
||||
});
|
||||
|
||||
const waitPromise = new Promise<void>((resolve, reject) => {
|
||||
performance.mark('waitForGroupConsistency:started');
|
||||
consistencyInterval.current = setInterval(() => {
|
||||
function logWaitingTime() {
|
||||
performance.mark('waitForGroupConsistency:finished');
|
||||
const duration = performance.measure(
|
||||
'waitForGroupConsistency',
|
||||
'waitForGroupConsistency:started',
|
||||
'waitForGroupConsistency:finished'
|
||||
);
|
||||
logMeasurement(
|
||||
'alerting:wait-for-group-consistency',
|
||||
{ duration: duration.duration },
|
||||
{ groupOrigin: groupIdentifier.groupOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
function checkGroupConsistency() {
|
||||
isGroupInSync(groupIdentifier)
|
||||
.then((inSync) => {
|
||||
setGroupConsistent(inSync);
|
||||
if (inSync) {
|
||||
performance.mark('waitForGroupConsistency:finished');
|
||||
const duration = performance.measure(
|
||||
'waitForGroupConsistency',
|
||||
'waitForGroupConsistency:started',
|
||||
'waitForGroupConsistency:finished'
|
||||
);
|
||||
logMeasurement(
|
||||
'alerting:wait-for-group-consistency',
|
||||
{ duration: duration.duration },
|
||||
{ groupOrigin: groupIdentifier.groupOrigin }
|
||||
);
|
||||
logWaitingTime();
|
||||
clearConsistencyInterval();
|
||||
resolve();
|
||||
} else {
|
||||
consistencyInterval.current = setTimeout(checkGroupConsistency, CONSISTENCY_CHECK_POOL_INTERVAL);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
clearConsistencyInterval();
|
||||
reject(error);
|
||||
});
|
||||
}, CONSISTENCY_CHECK_POOL_INTERVAL);
|
||||
}
|
||||
|
||||
performance.mark('waitForGroupConsistency:started');
|
||||
checkGroupConsistency();
|
||||
});
|
||||
|
||||
return Promise.race([timeoutPromise, waitPromise]);
|
||||
}
|
||||
|
||||
return { waitForGroupConsistency };
|
||||
return { waitForGroupConsistency, groupConsistent };
|
||||
}
|
||||
|
||||
export function usePrometheusConsistencyCheck() {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { HttpResponse, http } from 'msw';
|
||||
import { SetupServer, setupServer } from 'msw/node';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { AlertGroupUpdated } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||
import allHandlers from 'app/features/alerting/unified/mocks/server/all-handlers';
|
||||
import {
|
||||
setupAlertmanagerConfigMapDefaultState,
|
||||
@@ -29,6 +28,8 @@ import {
|
||||
} from '../../../plugins/datasource/alertmanager/types';
|
||||
import { DashboardSearchItem } from '../../search/types';
|
||||
|
||||
import { RulerGroupUpdatedResponse } from './api/alertRuleModel';
|
||||
|
||||
type Configurator<T> = (builder: T) => T;
|
||||
|
||||
export class AlertmanagerConfigBuilder {
|
||||
@@ -173,7 +174,7 @@ export function mockAlertRuleApi(server: SetupServer) {
|
||||
rulerRules: (dsName: string, response: RulerRulesConfigDTO) => {
|
||||
server.use(http.get(`/api/ruler/${dsName}/api/v1/rules`, () => HttpResponse.json(response)));
|
||||
},
|
||||
updateRule: (dsName: string, response: AlertGroupUpdated) => {
|
||||
updateRule: (dsName: string, response: RulerGroupUpdatedResponse) => {
|
||||
server.use(http.post(`/api/ruler/${dsName}/api/v1/rules/:namespaceUid`, () => HttpResponse.json(response)));
|
||||
},
|
||||
rulerRuleGroup: (dsName: string, namespace: string, group: string, response: RulerRuleGroupDTO) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
AlertQuery,
|
||||
GrafanaAlertState,
|
||||
GrafanaAlertStateDecision,
|
||||
GrafanaPromAlertingRuleDTO,
|
||||
GrafanaRuleDefinition,
|
||||
PromAlertingRuleState,
|
||||
PromRuleType,
|
||||
@@ -223,6 +224,17 @@ export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): Alert
|
||||
};
|
||||
};
|
||||
|
||||
export const mockGrafanaPromAlertingRule = (
|
||||
partial: Partial<GrafanaPromAlertingRuleDTO> = {}
|
||||
): GrafanaPromAlertingRuleDTO => {
|
||||
return {
|
||||
...mockPromAlertingRule(),
|
||||
uid: 'mock-rule-uid-123',
|
||||
folderUid: 'NAMESPACE_UID',
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {}): RulerGrafanaRuleDTO => {
|
||||
return {
|
||||
for: '',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PromRulesResponse,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { PREVIEW_URL, PROM_RULES_URL, PreviewResponse } from '../api/alertRuleApi';
|
||||
@@ -61,33 +62,93 @@ export const grafanaRulerRule: RulerGrafanaRuleDTO = {
|
||||
},
|
||||
};
|
||||
|
||||
export const grafanaRulerGroup: RulerRuleGroupDTO = {
|
||||
export const grafanaRulerGroup: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
|
||||
name: grafanaRulerGroupName,
|
||||
interval: '1m',
|
||||
rules: [grafanaRulerRule],
|
||||
};
|
||||
|
||||
export const grafanaRulerGroup2: RulerRuleGroupDTO = {
|
||||
export const grafanaRulerGroup2: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
|
||||
name: grafanaRulerGroupName2,
|
||||
interval: '1m',
|
||||
rules: [grafanaRulerRule],
|
||||
};
|
||||
|
||||
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = {
|
||||
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO<RulerGrafanaRuleDTO> = {
|
||||
name: 'empty-group',
|
||||
interval: '1m',
|
||||
rules: [],
|
||||
};
|
||||
|
||||
export const namespaceByUid: Record<string, { name: string; uid: string }> = {
|
||||
[grafanaRulerNamespace.uid]: grafanaRulerNamespace,
|
||||
[grafanaRulerNamespace2.uid]: grafanaRulerNamespace2,
|
||||
};
|
||||
// AKA Folder
|
||||
interface GrafanaNamespace {
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
|
||||
[grafanaRulerNamespace.uid]: [grafanaRulerGroup, grafanaRulerGroup2],
|
||||
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
|
||||
};
|
||||
export class RulerTestDb {
|
||||
private namespaces = new Map<string, string>(); // UID -> Name
|
||||
private groupsByNamespaceUid = new Map<string, RulerRuleGroupDTO[]>();
|
||||
|
||||
constructor(groups: Iterable<[RulerRuleGroupDTO, GrafanaNamespace]> = []) {
|
||||
for (const [group, namespace] of groups) {
|
||||
this.addGroup(group, namespace);
|
||||
}
|
||||
}
|
||||
addGroup(group: RulerRuleGroupDTO, namespace: GrafanaNamespace) {
|
||||
if (!this.namespaces.has(namespace.uid)) {
|
||||
this.namespaces.set(namespace.uid, namespace.name);
|
||||
}
|
||||
|
||||
const namespaceGroups = this.groupsByNamespaceUid.get(namespace.uid);
|
||||
if (!namespaceGroups) {
|
||||
this.groupsByNamespaceUid.set(namespace.uid, [group]);
|
||||
} else {
|
||||
namespaceGroups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
getRulerConfig(): RulerRulesConfigDTO {
|
||||
const config: RulerRulesConfigDTO = {};
|
||||
for (const [namespaceUid, groups] of this.groupsByNamespaceUid) {
|
||||
const namespaceName = this.namespaces.get(namespaceUid);
|
||||
if (!namespaceName) {
|
||||
throw new Error(`Namespace name for uid ${namespaceUid} not found`);
|
||||
}
|
||||
config[namespaceName] = groups;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
getNamespace(uid: string): RulerRulesConfigDTO | undefined {
|
||||
const namespaceGroups = this.groupsByNamespaceUid.get(uid);
|
||||
if (!namespaceGroups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const namespaceName = this.namespaces.get(uid);
|
||||
if (!namespaceName) {
|
||||
throw new Error(`Namespace name for uid ${uid} not found`);
|
||||
}
|
||||
|
||||
return { [namespaceName]: namespaceGroups };
|
||||
}
|
||||
|
||||
getGroup(uid: string, groupName: string): RulerRuleGroupDTO | undefined {
|
||||
const namespaceGroups = this.groupsByNamespaceUid.get(uid);
|
||||
if (!namespaceGroups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return namespaceGroups.find((group) => group.name === groupName);
|
||||
}
|
||||
}
|
||||
|
||||
export const rulerTestDb = new RulerTestDb([
|
||||
[grafanaRulerGroup, grafanaRulerNamespace],
|
||||
[grafanaRulerGroup2, grafanaRulerNamespace],
|
||||
[grafanaRulerEmptyGroup, grafanaRulerNamespace2],
|
||||
]);
|
||||
|
||||
//-------------------- for alert history tests we reuse these constants --------------------
|
||||
export const time_0 = 1718368710000;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FolderDTO } from 'app/types';
|
||||
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { PromRuleGroupDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
@@ -191,6 +191,10 @@ export function setPrometheusRules(ds: DataSourceLike, groups: PromRuleGroupDTO[
|
||||
server.use(http.get(`/api/prometheus/${ds.uid}/api/v1/rules`, paginatedHandlerFor(groups)));
|
||||
}
|
||||
|
||||
export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
|
||||
server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups)));
|
||||
}
|
||||
|
||||
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
|
||||
export const removePlugin = (pluginId: string) => {
|
||||
delete config.apps[pluginId];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PromRuleGroupDTO,
|
||||
PromRuleType,
|
||||
RulerAlertingRuleDTO,
|
||||
RulerCloudRuleDTO,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRecordingRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
@@ -18,34 +19,34 @@ import {
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
import { namespaces } from '../mimirRulerApi';
|
||||
|
||||
const prometheusRuleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
|
||||
name: `test-rule-${sequence}`,
|
||||
import { MIMIR_DATASOURCE_UID, PROMETHEUS_DATASOURCE_UID } from './constants';
|
||||
|
||||
interface PromRuleFactoryTransientParams {
|
||||
namePrefix?: string;
|
||||
}
|
||||
|
||||
class PromRuleFactory extends Factory<PromAlertingRuleDTO, PromRuleFactoryTransientParams> {
|
||||
fromRuler(rulerRule: RulerAlertingRuleDTO) {
|
||||
return this.params({
|
||||
name: rulerRule.alert,
|
||||
query: rulerRule.expr,
|
||||
type: PromRuleType.Alerting,
|
||||
labels: rulerRule.labels,
|
||||
annotations: rulerRule.annotations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const prometheusRuleFactory = PromRuleFactory.define(({ sequence, transientParams: { namePrefix } }) => ({
|
||||
name: `${namePrefix ? `${namePrefix}-` : ''}test-rule-${sequence}`,
|
||||
query: 'test-query',
|
||||
state: PromAlertingRuleState.Inactive,
|
||||
type: PromRuleType.Alerting,
|
||||
type: PromRuleType.Alerting as const,
|
||||
health: 'ok',
|
||||
labels: { team: 'infra' },
|
||||
}));
|
||||
|
||||
const rulerAlertingRuleFactory = Factory.define<RulerAlertingRuleDTO>(({ sequence }) => ({
|
||||
alert: `ruler-alerting-rule-${sequence}`,
|
||||
expr: 'vector(0)',
|
||||
annotations: { 'annotation-key-1': 'annotation-value-1' },
|
||||
labels: { 'label-key-1': 'label-value-1' },
|
||||
for: '5m',
|
||||
}));
|
||||
|
||||
const rulerRecordingRuleFactory = Factory.define<RulerRecordingRuleDTO>(({ sequence }) => ({
|
||||
record: `ruler-recording-rule-${sequence}`,
|
||||
expr: 'vector(0)',
|
||||
labels: { 'label-key-1': 'label-value-1' },
|
||||
}));
|
||||
|
||||
const rulerRuleGroupFactory = Factory.define<RulerRuleGroupDTO>(({ sequence }) => ({
|
||||
name: `ruler-rule-group-${sequence}`,
|
||||
rules: [],
|
||||
interval: '1m',
|
||||
labels: {},
|
||||
annotations: {},
|
||||
}));
|
||||
|
||||
const prometheusRuleGroupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
|
||||
@@ -61,7 +62,49 @@ const prometheusRuleGroupFactory = Factory.define<PromRuleGroupDTO>(({ sequence
|
||||
return group;
|
||||
});
|
||||
|
||||
const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence, params, afterBuild }) => {
|
||||
const rulerAlertingRuleFactory = Factory.define<RulerAlertingRuleDTO>(({ sequence }) => ({
|
||||
alert: `test-rule-${sequence}`,
|
||||
expr: 'up = 1',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test alert' },
|
||||
}));
|
||||
|
||||
const rulerRecordingRuleFactory = Factory.define<RulerRecordingRuleDTO>(({ sequence }) => ({
|
||||
record: `ruler-recording-rule-${sequence}`,
|
||||
expr: 'vector(0)',
|
||||
labels: {},
|
||||
}));
|
||||
|
||||
const rulerGroupFactory = Factory.define<RulerRuleGroupDTO<RulerCloudRuleDTO>, { addToNamespace: string }>(
|
||||
({ sequence, transientParams, afterBuild }) => {
|
||||
afterBuild((group) => {
|
||||
if (transientParams.addToNamespace) {
|
||||
if (!namespaces[transientParams.addToNamespace]) {
|
||||
namespaces[transientParams.addToNamespace] = [];
|
||||
}
|
||||
namespaces[transientParams.addToNamespace].push(group);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: `test-group-${sequence}`,
|
||||
interval: '1m',
|
||||
rules: rulerAlertingRuleFactory.buildList(3),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
class DataSourceFactory extends Factory<DataSourceInstanceSettings> {
|
||||
vanillaPrometheus() {
|
||||
return this.params({ uid: PROMETHEUS_DATASOURCE_UID, name: 'Prometheus' });
|
||||
}
|
||||
|
||||
mimir() {
|
||||
return this.params({ uid: MIMIR_DATASOURCE_UID, name: 'Mimir' });
|
||||
}
|
||||
}
|
||||
|
||||
const dataSourceFactory = DataSourceFactory.define(({ sequence, params, afterBuild }) => {
|
||||
afterBuild((dataSource) => {
|
||||
config.datasources[dataSource.name] = dataSource;
|
||||
setupDataSources(...Object.values(config.datasources));
|
||||
@@ -73,7 +116,7 @@ const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence
|
||||
uid,
|
||||
type: DataSourceType.Prometheus,
|
||||
name: `Prometheus-${uid}`,
|
||||
access: 'proxy',
|
||||
access: 'proxy' as const,
|
||||
url: `/api/datasources/proxy/uid/${uid}`,
|
||||
jsonData: {},
|
||||
meta: {
|
||||
@@ -142,7 +185,7 @@ export const alertingFactory = {
|
||||
rule: prometheusRuleFactory,
|
||||
},
|
||||
ruler: {
|
||||
group: rulerRuleGroupFactory,
|
||||
group: rulerGroupFactory,
|
||||
alertingRule: rulerAlertingRuleFactory,
|
||||
recordingRule: rulerRecordingRuleFactory,
|
||||
grafana: {
|
||||
|
||||
@@ -10,25 +10,14 @@ import {
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} from '../../../../../../types/unified-alerting-dto';
|
||||
import { AlertGroupUpdated } from '../../../api/alertRuleApi';
|
||||
import {
|
||||
getHistoryResponse,
|
||||
grafanaRulerRule,
|
||||
namespaceByUid,
|
||||
namespaces,
|
||||
time_0,
|
||||
time_plus_30,
|
||||
} from '../../grafanaRulerApi';
|
||||
import { GrafanaGroupUpdatedResponse } from '../../../api/alertRuleModel';
|
||||
import { getHistoryResponse, grafanaRulerRule, rulerTestDb, time_0, time_plus_30 } from '../../grafanaRulerApi';
|
||||
import { HandlerOptions } from '../configure';
|
||||
export const rulerRulesHandler = () => {
|
||||
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
|
||||
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
|
||||
acc[namespaceByUid[namespaceUid].name] = groups;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return HttpResponse.json<RulerRulesConfigDTO>(response);
|
||||
});
|
||||
export const rulerRulesHandler = () => {
|
||||
return http.get(`/api/ruler/grafana/api/v1/rules`, () =>
|
||||
HttpResponse.json<RulerRulesConfigDTO>(rulerTestDb.getRulerConfig())
|
||||
);
|
||||
};
|
||||
|
||||
export const prometheusRulesHandler = () => {
|
||||
@@ -40,14 +29,12 @@ export const prometheusRulesHandler = () => {
|
||||
export const getRulerRuleNamespaceHandler = () =>
|
||||
http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => {
|
||||
// This mimic API response as closely as possible - Invalid folderUid returns 403
|
||||
const namespace = namespaces[folderUid];
|
||||
const namespace = rulerTestDb.getNamespace(folderUid);
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json<RulerRulesConfigDTO>({
|
||||
[namespaceByUid[folderUid].name]: namespaces[folderUid],
|
||||
});
|
||||
return HttpResponse.json<RulerRulesConfigDTO>(namespace);
|
||||
});
|
||||
|
||||
export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
|
||||
@@ -65,12 +52,12 @@ export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
|
||||
|
||||
// This mimic API response as closely as possible.
|
||||
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
|
||||
const namespace = namespaces[folderUid];
|
||||
const namespace = rulerTestDb.getNamespace(folderUid);
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json<AlertGroupUpdated>({
|
||||
return HttpResponse.json<GrafanaGroupUpdatedResponse>({
|
||||
message: 'updated',
|
||||
updated: [],
|
||||
});
|
||||
@@ -86,12 +73,13 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
|
||||
|
||||
// This mimic API response as closely as possible.
|
||||
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
|
||||
const namespace = namespaces[folderUid];
|
||||
// This should be fixed soon to return 404 instead of 202
|
||||
const namespace = rulerTestDb.getNamespace(folderUid);
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
const matchingGroup = namespace.find((group) => group.name === groupName);
|
||||
const matchingGroup = rulerTestDb.getGroup(folderUid, groupName);
|
||||
return HttpResponse.json<RulerRuleGroupDTO>({
|
||||
name: groupName,
|
||||
interval: matchingGroup?.interval,
|
||||
@@ -109,7 +97,7 @@ export const deleteRulerRuleGroupHandler = (options?: HandlerOptions) =>
|
||||
return options.response;
|
||||
}
|
||||
|
||||
const namespace = namespaces[folderUid];
|
||||
const namespace = rulerTestDb.getNamespace(folderUid);
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { PluginExtensionPoints } from '@grafana/data';
|
||||
import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { CombinedRule, Rule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import { PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
@@ -21,14 +21,21 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
|
||||
|
||||
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
|
||||
|
||||
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifierV2) {
|
||||
export function useRulePluginLinkExtension(rule: Rule | undefined, groupIdentifier: RuleGroupIdentifierV2) {
|
||||
// This ref provides a stable reference to an empty array, which is used to avoid re-renders when the rule is undefined.
|
||||
const emptyResponse = useRef<PluginExtensionLink[]>([]);
|
||||
|
||||
const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier);
|
||||
const { links } = usePluginLinks(ruleExtensionPoint);
|
||||
|
||||
if (!rule) {
|
||||
return emptyResponse.current;
|
||||
}
|
||||
|
||||
const ruleOrigin = getRulePluginOrigin(rule);
|
||||
const ruleType = rule.type;
|
||||
if (!ruleOrigin || !ruleType) {
|
||||
return [];
|
||||
return emptyResponse.current;
|
||||
}
|
||||
|
||||
const { pluginId } = ruleOrigin;
|
||||
@@ -57,8 +64,12 @@ interface EmptyExtensionPoint {
|
||||
|
||||
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
|
||||
|
||||
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
|
||||
function useRuleExtensionPoint(rule: Rule | undefined, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
|
||||
return useMemo<RuleExtensionPoint>(() => {
|
||||
if (!rule) {
|
||||
return { extensionPointId: '' };
|
||||
}
|
||||
|
||||
const ruleType = rule.type;
|
||||
const { namespace, groupName } = groupIdentifier;
|
||||
const namespaceIdentifier = 'uid' in namespace ? namespace.uid : namespace.name;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, within } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { setPrometheusRules } from '../mocks/server/configure';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
import { createViewLinkV2 } from '../utils/misc';
|
||||
import { fromRulerRuleAndGroupIdentifierV2 } from '../utils/rule-id';
|
||||
|
||||
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
|
||||
import { createViewLinkFromIdentifier } from './DataSourceRuleListItem';
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleExternalWrite]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const ui = {
|
||||
ruleItem: (ruleName: string | RegExp) => byRole('treeitem', { name: ruleName }),
|
||||
editButton: byRole('link', { name: 'Edit' }),
|
||||
moreButton: byRole('button', { name: 'More' }),
|
||||
};
|
||||
|
||||
const vanillaPromDs = alertingFactory.dataSource.vanillaPrometheus().build();
|
||||
const mimirDs = alertingFactory.dataSource.mimir().build();
|
||||
|
||||
describe('DataSourceGroupLoader', () => {
|
||||
const promRuleSource = getDataSourceIdentifier(vanillaPromDs);
|
||||
const mimirRuleSource = getDataSourceIdentifier(mimirDs);
|
||||
|
||||
describe('Vanilla Prometheus', () => {
|
||||
const promGroup = alertingFactory.prometheus.group.build({
|
||||
file: 'test-namespace',
|
||||
rules: [
|
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-1' }),
|
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-2' }),
|
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule-3' }),
|
||||
],
|
||||
});
|
||||
const groupIdentifier = getPromGroupIdentifier(promRuleSource, promGroup);
|
||||
|
||||
it('should render a list of rules for data sources without ruler', async () => {
|
||||
setPrometheusRules(vanillaPromDs, [promGroup]);
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const ruleListItems = await ui.ruleItem(/prom-only-rule/).findAll();
|
||||
expect(ruleListItems).toHaveLength(3);
|
||||
|
||||
promGroup.rules.forEach((rule, index) => {
|
||||
const ruleLink = within(ruleListItems[index]).getByRole('link', { name: `prom-only-rule-${index + 1}` });
|
||||
expect(ruleLink).toHaveAttribute('href', createViewLinkV2(groupIdentifier, rule));
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render rule action buttons', async () => {
|
||||
setPrometheusRules(vanillaPromDs, [promGroup]);
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const ruleListItems = await ui.ruleItem(/prom-only-rule/).findAll();
|
||||
expect(ruleListItems).toHaveLength(3);
|
||||
|
||||
ruleListItems.forEach((ruleListItem) => {
|
||||
expect(ui.editButton.query(ruleListItem)).not.toBeInTheDocument();
|
||||
expect(ui.moreButton.query(ruleListItem)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruler-enabled data sources', () => {
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'mimir-rule-1' });
|
||||
const rulerOnlyRule = alertingFactory.ruler.alertingRule.build({ alert: 'mimir-only-rule' });
|
||||
alertingFactory.ruler.group.build(
|
||||
{ name: 'mimir-group', rules: [rulerRule, rulerOnlyRule] },
|
||||
{ transient: { addToNamespace: 'mimir-namespace' } }
|
||||
);
|
||||
const promGroup = alertingFactory.prometheus.group.build({
|
||||
name: 'mimir-group',
|
||||
file: 'mimir-namespace',
|
||||
rules: [
|
||||
alertingFactory.prometheus.rule.fromRuler(rulerRule).build(),
|
||||
alertingFactory.prometheus.rule.build({ name: 'prom-only-rule' }),
|
||||
],
|
||||
});
|
||||
const groupIdentifier = getPromGroupIdentifier(mimirRuleSource, promGroup);
|
||||
|
||||
beforeEach(() => {
|
||||
setPrometheusRules(mimirDs, [promGroup]);
|
||||
});
|
||||
|
||||
it('should render a list of rules for data sources with ruler', async () => {
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const ruleListItems = await ui.ruleItem(/mimir-rule/).findAll();
|
||||
expect(ruleListItems).toHaveLength(1);
|
||||
|
||||
const ruleLink = within(ruleListItems[0]).getByRole('link', { name: 'mimir-rule-1' });
|
||||
expect(ruleLink).toHaveAttribute('href', getRuleLink(groupIdentifier, rulerRule));
|
||||
});
|
||||
|
||||
it('should render Edit and More buttons for rules that are present in ruler and prometheus', async () => {
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const mimirRule1 = await ui.ruleItem(/mimir-rule/).find();
|
||||
|
||||
expect(await ui.editButton.find(mimirRule1)).toBeInTheDocument();
|
||||
expect(await ui.moreButton.find(mimirRule1)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render creating state if a rules is only present in ruler', async () => {
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const mimirOnlyItem = await ui.ruleItem(/mimir-only-rule/).find();
|
||||
expect(within(mimirOnlyItem).getByTitle('Creating')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render deleting state if a rule is only present in prometheus', async () => {
|
||||
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
|
||||
|
||||
const promOnlyItem = await ui.ruleItem(/prom-only-rule/).find();
|
||||
expect(within(promOnlyItem).getByTitle('Deleting')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getPromGroupIdentifier(
|
||||
promRuleSource: DataSourceRulesSourceIdentifier,
|
||||
group: PromRuleGroupDTO
|
||||
): DataSourceRuleGroupIdentifier {
|
||||
return {
|
||||
rulesSource: promRuleSource,
|
||||
groupName: group.name,
|
||||
namespace: { name: group.file },
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
function getDataSourceIdentifier(dataSource: DataSourceInstanceSettings): DataSourceRulesSourceIdentifier {
|
||||
return {
|
||||
uid: dataSource.uid,
|
||||
name: dataSource.name,
|
||||
ruleSourceType: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
function getRuleLink(groupIdentifier: DataSourceRuleGroupIdentifier, rulerRule: RulerRuleDTO) {
|
||||
return createViewLinkFromIdentifier(fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rulerRule));
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { DataSourceRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import {
|
||||
PromRuleDTO,
|
||||
PromRuleGroupDTO,
|
||||
RulerCloudRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
RulesSourceApplication,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { prometheusApi } from '../api/prometheusApi';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
import { getRuleName, isCloudRulerGroup } from '../utils/rules';
|
||||
|
||||
import { DataSourceRuleListItem } from './DataSourceRuleListItem';
|
||||
import { RuleOperationListItem } from './components/AlertRuleListItem';
|
||||
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
|
||||
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
import { RuleOperation } from './components/RuleListIcon';
|
||||
import { matchRulesGroup } from './ruleMatching';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const { useGetGroupsQuery } = prometheusApi;
|
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
|
||||
export interface DataSourceGroupLoaderProps {
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
/**
|
||||
* Used to display the same number of skeletons as there are rules
|
||||
* The number of rules is typically known from paginated Prometheus response
|
||||
* Ruler response might contain different number of rules, but in most cases what we get from Prometheus is fine
|
||||
*/
|
||||
expectedRulesCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an evaluation group from Prometheus and Ruler endpoints.
|
||||
* Displays a loading skeleton while the data is being fetched.
|
||||
* Polls the Prometheus endpoint every 20 seconds to refresh the data.
|
||||
*/
|
||||
export function DataSourceGroupLoader({ groupIdentifier, expectedRulesCount = 3 }: DataSourceGroupLoaderProps) {
|
||||
const { namespace, groupName } = groupIdentifier;
|
||||
const namespaceName = namespace.name;
|
||||
|
||||
const {
|
||||
data: promResponse,
|
||||
isLoading: isPromResponseLoading,
|
||||
isError: isPromResponseError,
|
||||
} = useGetGroupsQuery(
|
||||
{
|
||||
ruleSource: { uid: groupIdentifier.rulesSource.uid },
|
||||
namespace: namespaceName,
|
||||
groupName: groupName,
|
||||
},
|
||||
{ pollingInterval: RULE_LIST_POLL_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const {
|
||||
data: dsFeatures,
|
||||
isLoading: isDsFeaturesLoading,
|
||||
isError: isDsFeaturesError,
|
||||
} = useDiscoverDsFeaturesQuery({
|
||||
uid: groupIdentifier.rulesSource.uid,
|
||||
});
|
||||
|
||||
const {
|
||||
data: rulerGroup,
|
||||
error: rulerGroupError,
|
||||
isFetching: isRulerGroupFetching,
|
||||
isError: isRulerGroupError,
|
||||
} = useGetRuleGroupForNamespaceQuery(
|
||||
dsFeatures?.rulerConfig
|
||||
? {
|
||||
rulerConfig: dsFeatures?.rulerConfig,
|
||||
namespace: namespaceName,
|
||||
group: groupName,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const isLoading = isPromResponseLoading || isDsFeaturesLoading || isRulerGroupFetching;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: expectedRulesCount }).map((_, index) => (
|
||||
<AlertRuleListItemSkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isError = isPromResponseError || isDsFeaturesError || isRulerGroupError;
|
||||
if (isError) {
|
||||
if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) {
|
||||
return (
|
||||
<Alert severity="warning" title={t('alerting.ds-group-loader.group-deleting', 'The group is being deleted')} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.ds-group-loader.group-load-failed',
|
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}',
|
||||
{ groupName, namespaceName }
|
||||
)}
|
||||
severity="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// There should be always only one group in the response but some Prometheus-compatible data sources
|
||||
// implement different filter parameters
|
||||
const promGroup = promResponse?.data.groups.find((g) => g.file === namespaceName && g.name === groupName);
|
||||
if (dsFeatures?.rulerConfig && rulerGroup && isCloudRulerGroup(rulerGroup) && promGroup) {
|
||||
return (
|
||||
<RulerBasedGroupRules
|
||||
groupIdentifier={groupIdentifier}
|
||||
promGroup={promGroup}
|
||||
rulerGroup={rulerGroup}
|
||||
application={dsFeatures.application}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Data source without ruler
|
||||
if (promGroup) {
|
||||
return (
|
||||
<>
|
||||
{promGroup.rules.map((rule) => (
|
||||
<DataSourceRuleListItem
|
||||
key={hashRule(rule)}
|
||||
rule={rule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
application={dsFeatures?.application}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never happen
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.ds-group-loader.group-load-failed',
|
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}',
|
||||
{ groupName, namespaceName }
|
||||
)}
|
||||
severity="warning"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RulerBasedGroupRulesProps {
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
promGroup: PromRuleGroupDTO<PromRuleDTO>;
|
||||
rulerGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>;
|
||||
application: RulesSourceApplication;
|
||||
}
|
||||
|
||||
export function RulerBasedGroupRules({
|
||||
groupIdentifier,
|
||||
application,
|
||||
promGroup,
|
||||
rulerGroup,
|
||||
}: RulerBasedGroupRulesProps) {
|
||||
const { namespace, groupName } = groupIdentifier;
|
||||
|
||||
const { matches, promOnlyRules } = useMemo(() => {
|
||||
return matchRulesGroup(rulerGroup, promGroup);
|
||||
}, [promGroup, rulerGroup]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rulerGroup.rules.map((rulerRule) => {
|
||||
const promRule = matches.get(rulerRule);
|
||||
|
||||
return promRule ? (
|
||||
<DataSourceRuleListItem
|
||||
key={hashRule(promRule)}
|
||||
rule={promRule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
application={application}
|
||||
actions={
|
||||
<RuleActionsButtons rule={rulerRule} promRule={promRule} groupIdentifier={groupIdentifier} compact />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RuleOperationListItem
|
||||
key={getRuleName(rulerRule)}
|
||||
name={getRuleName(rulerRule)}
|
||||
namespace={namespace.name}
|
||||
group={groupName}
|
||||
rulesSource={groupIdentifier.rulesSource}
|
||||
application={application}
|
||||
operation={RuleOperation.Creating}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{promOnlyRules.map((rule) => (
|
||||
<RuleOperationListItem
|
||||
key={rule.name}
|
||||
name={rule.name}
|
||||
namespace={namespace.name}
|
||||
group={groupName}
|
||||
rulesSource={groupIdentifier.rulesSource}
|
||||
application={application}
|
||||
operation={RuleOperation.Deleting}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromRuleType, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Annotation } from '../utils/constants';
|
||||
import { fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id';
|
||||
import { getRuleName, getRulePluginOrigin, rulerRuleType } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
|
||||
import {
|
||||
AlertRuleListItem,
|
||||
RecordingRuleListItem,
|
||||
RuleListItemCommonProps,
|
||||
UnknownRuleListItem,
|
||||
} from './components/AlertRuleListItem';
|
||||
|
||||
export interface DataSourceRuleListItemProps {
|
||||
rule: Rule;
|
||||
rulerRule?: RulerRuleDTO;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
application?: RulesSourceApplication;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataSourceRuleListItem({
|
||||
rule,
|
||||
rulerRule,
|
||||
groupIdentifier,
|
||||
application,
|
||||
actions,
|
||||
}: DataSourceRuleListItemProps) {
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleIdentifier = rulerRule
|
||||
? fromRulerRule(rulesSource.name, namespace.name, groupName, rulerRule)
|
||||
: fromRule(rulesSource.name, namespace.name, groupName, rule);
|
||||
const href = createViewLinkFromIdentifier(ruleIdentifier);
|
||||
const originMeta = getRulePluginOrigin(rule);
|
||||
|
||||
// If ruler rule is available, we should use it as it contains fresh data
|
||||
const ruleName = rulerRule ? getRuleName(rulerRule) : rule.name;
|
||||
const labels = rulerRule ? rulerRule.labels : rule.labels;
|
||||
|
||||
const commonProps: RuleListItemCommonProps = {
|
||||
name: ruleName,
|
||||
rulesSource: rulesSource,
|
||||
application: application,
|
||||
group: groupName,
|
||||
namespace: namespace.name,
|
||||
href,
|
||||
health: rule.health,
|
||||
error: rule.lastError,
|
||||
labels,
|
||||
actions,
|
||||
origin: originMeta,
|
||||
};
|
||||
|
||||
switch (rule.type) {
|
||||
case PromRuleType.Alerting:
|
||||
const annotations = (rulerRuleType.any.alertingRule(rulerRule) ? rulerRule.annotations : rule.annotations) ?? {};
|
||||
const summary = annotations[Annotation.summary];
|
||||
|
||||
return (
|
||||
<AlertRuleListItem {...commonProps} summary={summary} state={rule.state} instancesCount={rule.alerts?.length} />
|
||||
);
|
||||
case PromRuleType.Recording:
|
||||
return <RecordingRuleListItem {...commonProps} />;
|
||||
default:
|
||||
return <UnknownRuleListItem ruleName={ruleName} groupIdentifier={groupIdentifier} ruleDefinition={rule} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
|
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { DataSourceRuleGroupIdentifier, Rule } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { equal, fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id';
|
||||
import { getRulePluginOrigin, prometheusRuleType } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
import { isCloudRulerGroup } from '../utils/rules';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { DataSourceRuleListItem } from './DataSourceRuleListItem';
|
||||
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
|
||||
import { getMatchingRulerRule } from './ruleMatching';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
@@ -26,25 +26,12 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
}: DataSourceRuleLoaderProps) {
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);
|
||||
const href = createViewLinkFromIdentifier(ruleIdentifier);
|
||||
const originMeta = getRulePluginOrigin(rule);
|
||||
const { data: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
|
||||
|
||||
// @TODO work with context API to propagate rulerConfig and such
|
||||
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
|
||||
|
||||
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
|
||||
const {
|
||||
isLoading,
|
||||
data: rulerRuleGroup,
|
||||
// error,
|
||||
} = useGetRuleGroupForNamespaceQuery(
|
||||
{
|
||||
namespace: namespace.name,
|
||||
group: groupName,
|
||||
rulerConfig: dataSourceInfo?.rulerConfig!,
|
||||
},
|
||||
{ skip: !dataSourceInfo?.rulerConfig }
|
||||
const { isLoading, data: rulerRuleGroup } = useGetRuleGroupForNamespaceQuery(
|
||||
dsFeatures?.rulerConfig
|
||||
? { namespace: namespace.name, group: groupName, rulerConfig: dsFeatures?.rulerConfig }
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const rulerRule = useMemo(() => {
|
||||
@@ -52,10 +39,12 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
return;
|
||||
}
|
||||
|
||||
return rulerRuleGroup.rules.find((rule) =>
|
||||
equal(fromRulerRule(rulesSource.name, namespace.name, groupName, rule), ruleIdentifier)
|
||||
);
|
||||
}, [rulesSource, namespace, groupName, ruleIdentifier, rulerRuleGroup]);
|
||||
if (!isCloudRulerGroup(rulerRuleGroup)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getMatchingRulerRule(rulerRuleGroup, rule);
|
||||
}, [rulerRuleGroup, rule]);
|
||||
|
||||
// 1. get the rule from the ruler API with "ruleWithLocation"
|
||||
// 1.1 skip this if this datasource does not have a ruler
|
||||
@@ -74,53 +63,13 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
return null;
|
||||
}, [groupIdentifier, isLoading, rule, rulerRule]);
|
||||
|
||||
if (prometheusRuleType.alertingRule(rule)) {
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
name={rule.name}
|
||||
rulesSource={rulesSource}
|
||||
application={dataSourceInfo?.application}
|
||||
group={groupName}
|
||||
namespace={namespace.name}
|
||||
href={href}
|
||||
summary={rule.annotations?.summary}
|
||||
state={rule.state}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
instancesCount={rule.alerts?.length}
|
||||
actions={actions}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (prometheusRuleType.recordingRule(rule)) {
|
||||
return (
|
||||
<RecordingRuleListItem
|
||||
name={rule.name}
|
||||
rulesSource={rulesSource}
|
||||
application={dataSourceInfo?.application}
|
||||
group={groupName}
|
||||
namespace={namespace.name}
|
||||
href={href}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
actions={actions}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
return (
|
||||
<DataSourceRuleListItem
|
||||
rule={rule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
application={dsFeatures?.application}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
|
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
@@ -19,9 +19,15 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const mimirGroups = alertingFactory.prometheus.group.buildList(5000, { file: 'test-mimir-namespace' });
|
||||
const mimirGroups = alertingFactory.prometheus.group.buildList(5000, {
|
||||
file: 'test-mimir-namespace',
|
||||
rules: alertingFactory.prometheus.rule.buildList(3, undefined, { transient: { namePrefix: 'mimir' } }),
|
||||
});
|
||||
alertingFactory.prometheus.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.prometheus.group.buildList(200, { file: 'test-prometheus-namespace' });
|
||||
const prometheusGroups = alertingFactory.prometheus.group.buildList(200, {
|
||||
file: 'test-prometheus-namespace',
|
||||
rules: alertingFactory.prometheus.rule.buildList(3, undefined, { transient: { namePrefix: 'prometheus' } }),
|
||||
});
|
||||
|
||||
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
@@ -34,7 +40,9 @@ beforeEach(() => {
|
||||
const io = mockIntersectionObserver();
|
||||
|
||||
describe('RuleList - FilterView', () => {
|
||||
jest.setTimeout(60 * 1000);
|
||||
jest.retryTimes(2);
|
||||
|
||||
it('should render multiple pages of results', async () => {
|
||||
render(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
|
||||
|
||||
@@ -48,39 +56,40 @@ describe('RuleList - FilterView', () => {
|
||||
it('should filter results by group and rule name ', async () => {
|
||||
render(
|
||||
<FilterView
|
||||
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-8' })}
|
||||
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-2' })}
|
||||
/>
|
||||
);
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
const matchingRule = (await screen.findAllByRole('treeitem')).at(0);
|
||||
expect(matchingRule).toBeInTheDocument();
|
||||
const matchingRule = await screen.findByRole('treeitem', {
|
||||
name: /mimir-test-rule-2/,
|
||||
});
|
||||
|
||||
expect(matchingRule).toHaveTextContent('test-rule-8');
|
||||
expect(matchingRule).toHaveTextContent('mimir-test-rule-2');
|
||||
expect(matchingRule).toHaveTextContent('test-mimir-namespace');
|
||||
expect(matchingRule).toHaveTextContent('test-group-4501');
|
||||
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display rules from multiple datasources', async () => {
|
||||
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-5' })} />);
|
||||
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-2' })} />);
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
// Mimir has 11 matching rules, 181, 1810, 1811 ... 1819
|
||||
const matchingMimirRules = await screen.findAllByRole('treeitem', {
|
||||
name: /test-rule-5 Mimir test-mimir-namespace test-group-181/,
|
||||
name: /mimir-test-rule-2/,
|
||||
});
|
||||
const matchingPrometheusRule = await screen.findByRole('treeitem', {
|
||||
name: /test-rule-5 Prometheus test-prometheus-namespace test-group-181/,
|
||||
name: /prometheus-test-rule-2/,
|
||||
});
|
||||
|
||||
expect(matchingMimirRules).toHaveLength(11);
|
||||
expect(matchingPrometheusRule).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
|
||||
});
|
||||
}, 90000);
|
||||
|
||||
it('should display empty state when no rules are found', async () => {
|
||||
render(<FilterView filterState={getFilter({ groupName: 'non-existing-group' })} />);
|
||||
@@ -95,7 +104,7 @@ async function loadMoreResults() {
|
||||
act(() => {
|
||||
io.enterNode(screen.getByTestId('load-more-helper'));
|
||||
});
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 8000 });
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 80000 });
|
||||
}
|
||||
|
||||
function getFilter(overrides: Partial<RulesFilter> = {}): RulesFilter {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { catchError, take, tap, withAbort } from 'ix/asynciterable/operators';
|
||||
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||
|
||||
import { Card, EmptyState, Stack, Text } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { isLoading, useAsync } from '../hooks/useAsync';
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
@@ -13,7 +13,7 @@ import { DataSourceRuleLoader } from './DataSourceRuleLoader';
|
||||
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
|
||||
import LoadMoreHelper from './LoadMoreHelper';
|
||||
import { UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { AlertRuleListItemLoader } from './components/AlertRuleListItemLoader';
|
||||
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
|
||||
import {
|
||||
GrafanaRuleWithOrigin,
|
||||
PromRuleWithOrigin,
|
||||
@@ -133,13 +133,20 @@ function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
case 'datasource':
|
||||
return <DataSourceRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
default:
|
||||
return <UnknownRuleListItem key={key} rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
return (
|
||||
<UnknownRuleListItem
|
||||
key={key}
|
||||
ruleName={t('alerting.rule-list.unknown-rule-type', 'Unknown rule type')}
|
||||
groupIdentifier={groupIdentifier}
|
||||
ruleDefinition={rule}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{loading && (
|
||||
<>
|
||||
<AlertRuleListItemLoader />
|
||||
<AlertRuleListItemLoader />
|
||||
<AlertRuleListItemSkeleton />
|
||||
<AlertRuleListItemSkeleton />
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { render } from 'test/test-utils';
|
||||
import { byRole, byTitle } from 'testing-library-selector';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import {
|
||||
GrafanaPromRuleDTO,
|
||||
GrafanaPromRuleGroupDTO,
|
||||
PromAlertingRuleState,
|
||||
PromRuleType,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { mockGrafanaPromAlertingRule, mockGrafanaRulerRule } from '../mocks';
|
||||
import { grafanaRulerGroup, grafanaRulerNamespace } from '../mocks/grafanaRulerApi';
|
||||
import { setGrafanaPromRules } from '../mocks/server/configure';
|
||||
import { rulerRuleType } from '../utils/rules';
|
||||
import { intervalToSeconds } from '../utils/time';
|
||||
|
||||
import { GrafanaGroupLoader, matchRules } from './GrafanaGroupLoader';
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const ui = {
|
||||
ruleItem: (ruleName: string) => byRole('treeitem', { name: ruleName }),
|
||||
ruleStatus: (status: string) => byTitle(status),
|
||||
ruleLink: (ruleName: string) => byRole('link', { name: ruleName }),
|
||||
editButton: () => byRole('link', { name: 'Edit' }),
|
||||
moreButton: () => byRole('button', { name: 'More' }),
|
||||
};
|
||||
|
||||
describe('GrafanaGroupLoader', () => {
|
||||
it('should render rule with url when ruler and prom rule exist', async () => {
|
||||
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
|
||||
|
||||
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
|
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />);
|
||||
|
||||
const [rule1] = grafanaRulerGroup.rules;
|
||||
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
|
||||
|
||||
const ruleStatus = ui.ruleStatus('Normal').get(ruleListItem);
|
||||
expect(ruleStatus).toBeInTheDocument();
|
||||
|
||||
const ruleLink = ui.ruleLink(rule1.grafana_alert.title).get(ruleListItem);
|
||||
expect(ruleLink).toHaveAttribute('href', `/alerting/grafana/${rule1.grafana_alert.uid}/view`);
|
||||
});
|
||||
|
||||
it('should render rule with url and creating state when only ruler rule exists', async () => {
|
||||
setGrafanaPromRules([]);
|
||||
|
||||
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
|
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />);
|
||||
|
||||
const [rule1] = grafanaRulerGroup.rules;
|
||||
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
|
||||
|
||||
const creatingIcon = ui.ruleStatus('Creating').get(ruleListItem);
|
||||
expect(creatingIcon).toBeInTheDocument();
|
||||
|
||||
const ruleLink = ui.ruleLink(rule1.grafana_alert.title).get(ruleListItem);
|
||||
expect(ruleLink).toHaveAttribute('href', `/alerting/grafana/${rule1.grafana_alert.uid}/view`);
|
||||
});
|
||||
|
||||
it('should render delete rule operation list item when only prom rule exists', async () => {
|
||||
const promOnlyGroup: GrafanaPromRuleGroupDTO = {
|
||||
...rulerGroupToPromGroup(grafanaRulerGroup),
|
||||
name: 'prom-only-group',
|
||||
};
|
||||
|
||||
setGrafanaPromRules([promOnlyGroup]);
|
||||
|
||||
const groupIdentifier = getGroupIdentifier(promOnlyGroup);
|
||||
|
||||
render(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />);
|
||||
|
||||
const [rule1] = promOnlyGroup.rules;
|
||||
const promRule = await ui.ruleItem(rule1.name).find();
|
||||
|
||||
const deletingIcon = ui.ruleStatus('Deleting').get(promRule);
|
||||
expect(deletingIcon).toBeInTheDocument();
|
||||
|
||||
expect(ui.editButton().query(promRule)).not.toBeInTheDocument();
|
||||
expect(ui.moreButton().query(promRule)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchRules', () => {
|
||||
it('should return matches for all items and have empty promOnlyRules if all rules are matched by uid', () => {
|
||||
const rulerRules = [
|
||||
mockGrafanaRulerRule({ uid: '1' }),
|
||||
mockGrafanaRulerRule({ uid: '2' }),
|
||||
mockGrafanaRulerRule({ uid: '3' }),
|
||||
];
|
||||
|
||||
const promRules = rulerRules.map(rulerRuleToPromRule);
|
||||
|
||||
const { matches, promOnlyRules } = matchRules(promRules, rulerRules);
|
||||
|
||||
expect(matches.size).toBe(rulerRules.length);
|
||||
expect(promOnlyRules).toHaveLength(0);
|
||||
|
||||
for (const [rulerRule, promRule] of matches) {
|
||||
expect(rulerRule.grafana_alert.uid).toBe(promRule.uid);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unmatched prometheus rules in promOnlyRules array', () => {
|
||||
const rulerRules = [mockGrafanaRulerRule({ uid: '1' }), mockGrafanaRulerRule({ uid: '2' })];
|
||||
|
||||
const matchingPromRules = rulerRules.map(rulerRuleToPromRule);
|
||||
const unmatchedPromRules = [mockGrafanaPromAlertingRule({ uid: '3' }), mockGrafanaPromAlertingRule({ uid: '4' })];
|
||||
|
||||
const allPromRules = [...matchingPromRules, ...unmatchedPromRules];
|
||||
const { matches, promOnlyRules } = matchRules(allPromRules, rulerRules);
|
||||
|
||||
expect(matches.size).toBe(rulerRules.length);
|
||||
expect(promOnlyRules).toHaveLength(unmatchedPromRules.length);
|
||||
expect(promOnlyRules).toEqual(expect.arrayContaining(unmatchedPromRules));
|
||||
});
|
||||
|
||||
it('should not include ruler rules in matches if they have no prometheus counterpart', () => {
|
||||
const rulerRules = [
|
||||
mockGrafanaRulerRule({ uid: '1' }),
|
||||
mockGrafanaRulerRule({ uid: '2' }),
|
||||
mockGrafanaRulerRule({ uid: '3' }),
|
||||
];
|
||||
|
||||
// Only create prom rule for the second ruler rule
|
||||
const promRules = [rulerRuleToPromRule(rulerRules[1])];
|
||||
|
||||
const { matches, promOnlyRules } = matchRules(promRules, rulerRules);
|
||||
|
||||
expect(matches.size).toBe(1);
|
||||
expect(promOnlyRules).toHaveLength(0);
|
||||
|
||||
// Verify that only the second ruler rule is in matches
|
||||
expect(matches.has(rulerRules[0])).toBe(false);
|
||||
expect(matches.get(rulerRules[1])).toBe(promRules[0]);
|
||||
expect(matches.has(rulerRules[2])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function rulerGroupToPromGroup(group: RulerRuleGroupDTO<RulerGrafanaRuleDTO>): GrafanaPromRuleGroupDTO {
|
||||
return {
|
||||
folderUid: group.name,
|
||||
name: group.name,
|
||||
file: group.name,
|
||||
rules: group.rules.map<GrafanaPromRuleDTO>((r) => rulerRuleToPromRule(r)),
|
||||
interval: intervalToSeconds(group.interval ?? '1m'),
|
||||
};
|
||||
}
|
||||
|
||||
function rulerRuleToPromRule(rule: RulerGrafanaRuleDTO): GrafanaPromRuleDTO {
|
||||
return {
|
||||
name: rule.grafana_alert.title,
|
||||
query: JSON.stringify(rule.grafana_alert.data),
|
||||
uid: rule.grafana_alert.uid,
|
||||
folderUid: rule.grafana_alert.namespace_uid,
|
||||
health: 'ok',
|
||||
state: PromAlertingRuleState.Inactive,
|
||||
type: rulerRuleType.grafana.alertingRule(rule) ? PromRuleType.Alerting : PromRuleType.Recording,
|
||||
};
|
||||
}
|
||||
|
||||
function getGroupIdentifier(
|
||||
group: RulerRuleGroupDTO<RulerGrafanaRuleDTO> | GrafanaPromRuleGroupDTO
|
||||
): GrafanaRuleGroupIdentifier {
|
||||
return {
|
||||
groupName: group.name,
|
||||
namespace: { uid: grafanaRulerNamespace.uid },
|
||||
groupOrigin: 'grafana',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { prometheusApi } from '../api/prometheusApi';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
||||
import { GrafanaRulesSource } from '../utils/datasource';
|
||||
|
||||
import { GrafanaRuleListItem } from './GrafanaRuleLoader';
|
||||
import { RuleOperationListItem } from './components/AlertRuleListItem';
|
||||
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
|
||||
import { RuleOperation } from './components/RuleListIcon';
|
||||
|
||||
const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
|
||||
const { useGetGrafanaGroupsQuery } = prometheusApi;
|
||||
|
||||
export interface GrafanaGroupLoaderProps {
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
namespaceName: string;
|
||||
/**
|
||||
* Used to display the same number of skeletons as there are rules
|
||||
* The number of rules is typically known from paginated Prometheus response
|
||||
* Ruler response might contain different number of rules, but in most cases what we get from Prometheus is fine
|
||||
*/
|
||||
expectedRulesCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an evaluation group from Prometheus and Ruler endpoints.
|
||||
* Displays a loading skeleton while the data is being fetched.
|
||||
* Polls the Prometheus endpoint every 20 seconds to refresh the data.
|
||||
*/
|
||||
export function GrafanaGroupLoader({
|
||||
groupIdentifier,
|
||||
namespaceName,
|
||||
expectedRulesCount = 3, // 3 is a random number. Usually we get the number of rules from Prometheus response
|
||||
}: GrafanaGroupLoaderProps) {
|
||||
const { data: promResponse, isLoading: isPromResponseLoading } = useGetGrafanaGroupsQuery(
|
||||
{
|
||||
folderUid: groupIdentifier.namespace.uid,
|
||||
groupName: groupIdentifier.groupName,
|
||||
},
|
||||
{ pollingInterval: RULE_LIST_POLL_INTERVAL_MS }
|
||||
);
|
||||
const { data: rulerResponse, isLoading: isRulerGroupLoading } = useGetGrafanaRulerGroupQuery({
|
||||
folderUid: groupIdentifier.namespace.uid,
|
||||
groupName: groupIdentifier.groupName,
|
||||
});
|
||||
|
||||
const { matches, promOnlyRules } = useMemo(() => {
|
||||
const promRules = promResponse?.data.groups.at(0)?.rules ?? [];
|
||||
const rulerRules = rulerResponse?.rules ?? [];
|
||||
|
||||
return matchRules(promRules, rulerRules);
|
||||
}, [promResponse, rulerResponse]);
|
||||
|
||||
const isLoading = isPromResponseLoading || isRulerGroupLoading;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: expectedRulesCount }).map((_, index) => (
|
||||
<AlertRuleListItemSkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rulerResponse || !promResponse) {
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.group-loader.group-load-failed',
|
||||
'Failed to load rules from group {{ groupName }} in {{ namespaceName }}',
|
||||
{ groupName: groupIdentifier.groupName, namespaceName }
|
||||
)}
|
||||
severity="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rulerResponse.rules.map((rulerRule) => {
|
||||
const promRule = matches.get(rulerRule);
|
||||
|
||||
if (!promRule) {
|
||||
return (
|
||||
<GrafanaRuleListItem
|
||||
key={rulerRule.grafana_alert.uid}
|
||||
rule={promRule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
namespaceName={namespaceName}
|
||||
operation={RuleOperation.Creating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GrafanaRuleListItem
|
||||
key={promRule.uid}
|
||||
rule={promRule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
namespaceName={namespaceName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{promOnlyRules.map((rule) => (
|
||||
<RuleOperationListItem
|
||||
key={rule.uid}
|
||||
name={rule.name}
|
||||
namespace={namespaceName}
|
||||
group={groupIdentifier.groupName}
|
||||
rulesSource={GrafanaRulesSource}
|
||||
application="grafana"
|
||||
operation={RuleOperation.Deleting}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MatchingResult {
|
||||
matches: Map<RulerGrafanaRuleDTO, GrafanaPromRuleDTO>;
|
||||
/**
|
||||
* Rules that were already removed from the Ruler but the changes has not been yet propagated to Prometheus
|
||||
*/
|
||||
promOnlyRules: GrafanaPromRuleDTO[];
|
||||
}
|
||||
|
||||
export function matchRules(
|
||||
promRules: GrafanaPromRuleDTO[],
|
||||
rulerRules: RulerGrafanaRuleDTO[]
|
||||
): Readonly<MatchingResult> {
|
||||
const promRulesMap = new Map(promRules.map((rule) => [rule.uid, rule]));
|
||||
|
||||
const matchingResult = rulerRules.reduce<MatchingResult>(
|
||||
(acc, rulerRule) => {
|
||||
const { matches } = acc;
|
||||
const promRule = promRulesMap.get(rulerRule.grafana_alert.uid);
|
||||
if (promRule) {
|
||||
matches.set(rulerRule, promRule);
|
||||
promRulesMap.delete(rulerRule.grafana_alert.uid);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ matches: new Map(), promOnlyRules: [] }
|
||||
);
|
||||
|
||||
matchingResult.promOnlyRules.push(...promRulesMap.values());
|
||||
|
||||
return matchingResult;
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
import { GrafanaPromRuleDTO, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { GrafanaRulesSource } from '../utils/datasource';
|
||||
import { rulerRuleType } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { AlertRuleListItemLoader, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
|
||||
import {
|
||||
AlertRuleListItem,
|
||||
RecordingRuleListItem,
|
||||
RuleListItemCommonProps,
|
||||
UnknownRuleListItem,
|
||||
} from './components/AlertRuleListItem';
|
||||
import { AlertRuleListItemSkeleton, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
|
||||
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
import { RuleOperation } from './components/RuleListIcon';
|
||||
|
||||
const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
|
||||
|
||||
interface GrafanaRuleLoaderProps {
|
||||
rule: GrafanaPromRuleDTO;
|
||||
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
namespaceName: string;
|
||||
}
|
||||
|
||||
export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) {
|
||||
const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery(groupIdentifier);
|
||||
const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery({
|
||||
folderUid: groupIdentifier.namespace.uid,
|
||||
groupName: groupIdentifier.groupName,
|
||||
});
|
||||
|
||||
const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === rule.uid);
|
||||
|
||||
@@ -27,23 +38,48 @@ export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: Graf
|
||||
return <RulerRuleLoadingError rule={rule} />;
|
||||
}
|
||||
|
||||
return <AlertRuleListItemLoader />;
|
||||
return <AlertRuleListItemSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GrafanaRuleListItem
|
||||
rule={rule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
namespaceName={namespaceName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GrafanaRuleListItemProps {
|
||||
rule?: GrafanaPromRuleDTO;
|
||||
rulerRule: RulerGrafanaRuleDTO;
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
namespaceName: string;
|
||||
operation?: RuleOperation;
|
||||
}
|
||||
|
||||
export function GrafanaRuleListItem({
|
||||
rule,
|
||||
rulerRule,
|
||||
groupIdentifier,
|
||||
namespaceName,
|
||||
operation,
|
||||
}: GrafanaRuleListItemProps) {
|
||||
const {
|
||||
grafana_alert: { title, provenance, is_paused },
|
||||
grafana_alert: { uid, title, provenance, is_paused },
|
||||
annotations = {},
|
||||
labels = {},
|
||||
} = rulerRule;
|
||||
|
||||
const commonProps = {
|
||||
const commonProps: RuleListItemCommonProps = {
|
||||
name: title,
|
||||
rulesSource: GrafanaRulesSource,
|
||||
group: groupIdentifier.groupName,
|
||||
namespace: namespaceName,
|
||||
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
|
||||
health: rule.health,
|
||||
error: rule.lastError,
|
||||
href: createRelativeUrl(`/alerting/grafana/${uid}/view`),
|
||||
health: rule?.health,
|
||||
error: rule?.lastError,
|
||||
labels: labels,
|
||||
isProvisioned: Boolean(provenance),
|
||||
isPaused: is_paused,
|
||||
@@ -51,20 +87,23 @@ export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: Graf
|
||||
actions: <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />,
|
||||
};
|
||||
|
||||
if (rule.type === PromRuleType.Alerting) {
|
||||
if (rulerRuleType.grafana.alertingRule(rulerRule)) {
|
||||
const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
|
||||
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
{...commonProps}
|
||||
summary={annotations.summary}
|
||||
state={rule.state}
|
||||
instancesCount={rule.alerts?.length}
|
||||
state={promAlertingRule?.state}
|
||||
instancesCount={promAlertingRule?.alerts?.length}
|
||||
operation={operation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.type === PromRuleType.Recording) {
|
||||
if (rulerRuleType.grafana.recordingRule(rulerRule)) {
|
||||
return <RecordingRuleListItem {...commonProps} />;
|
||||
}
|
||||
|
||||
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
return <UnknownRuleListItem ruleName={title} groupIdentifier={groupIdentifier} ruleDefinition={rulerRule} />;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
import { groups } from '../utils/navigation';
|
||||
|
||||
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
|
||||
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
|
||||
import { DataSourceSection, DataSourceSectionProps } from './components/DataSourceSection';
|
||||
import { LazyPagination } from './components/LazyPagination';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
@@ -20,9 +20,10 @@ const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
|
||||
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
}
|
||||
|
||||
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true });
|
||||
|
||||
const groupsGenerator = useRef(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE));
|
||||
|
||||
@@ -85,24 +86,27 @@ interface RuleGroupListItemProps {
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
namespaceName: string;
|
||||
}
|
||||
|
||||
function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: RuleGroupListItemProps) {
|
||||
const rulesWithGroupId = useMemo(() => {
|
||||
return group.rules.map((rule) => {
|
||||
const groupIdentifier: DataSourceRuleGroupIdentifier = {
|
||||
rulesSource: rulesSourceIdentifier,
|
||||
namespace: { name: namespaceName },
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
return { rule, groupIdentifier };
|
||||
});
|
||||
}, [group, namespaceName, rulesSourceIdentifier]);
|
||||
const groupIdentifier: DataSourceRuleGroupIdentifier = useMemo(
|
||||
() => ({
|
||||
rulesSource: rulesSourceIdentifier,
|
||||
namespace: { name: namespaceName },
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
}),
|
||||
[rulesSourceIdentifier, namespaceName, group.name]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
|
||||
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
|
||||
<DataSourceRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
<ListGroup
|
||||
key={group.name}
|
||||
name={group.name}
|
||||
href={groups.detailsPageLink(rulesSourceIdentifier.uid, namespaceName, group.name)}
|
||||
isOpen={false}
|
||||
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
|
||||
>
|
||||
<DataSourceGroupLoader groupIdentifier={groupIdentifier} expectedRulesCount={group.rules.length} />
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { groups } from '../utils/navigation';
|
||||
|
||||
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
|
||||
import { DataSourceSection } from './components/DataSourceSection';
|
||||
import { LazyPagination } from './components/LazyPagination';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
@@ -17,7 +20,7 @@ import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGrou
|
||||
const GRAFANA_GROUP_PAGE_SIZE = 40;
|
||||
|
||||
export function PaginatedGrafanaLoader() {
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true });
|
||||
|
||||
const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE));
|
||||
|
||||
@@ -82,27 +85,28 @@ interface GrafanaRuleGroupListItemProps {
|
||||
group: GrafanaPromRuleGroupDTO;
|
||||
namespaceName: string;
|
||||
}
|
||||
|
||||
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
|
||||
const groupIdentifier: GrafanaRuleGroupIdentifier = {
|
||||
groupName: group.name,
|
||||
namespace: {
|
||||
uid: group.folderUid,
|
||||
},
|
||||
groupOrigin: 'grafana',
|
||||
};
|
||||
const groupIdentifier: GrafanaRuleGroupIdentifier = useMemo(
|
||||
() => ({
|
||||
groupName: group.name,
|
||||
namespace: {
|
||||
uid: group.folderUid,
|
||||
},
|
||||
groupOrigin: 'grafana',
|
||||
}),
|
||||
[group.name, group.folderUid]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
|
||||
{group.rules.map((rule) => {
|
||||
return (
|
||||
<GrafanaRuleLoader
|
||||
key={rule.uid}
|
||||
rule={rule}
|
||||
namespaceName={namespaceName}
|
||||
groupIdentifier={groupIdentifier}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ListGroup
|
||||
key={group.name}
|
||||
name={group.name}
|
||||
href={groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name)}
|
||||
isOpen={false}
|
||||
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
|
||||
>
|
||||
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={namespaceName} />
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from 'test/test-utils';
|
||||
import { byTestId } from 'testing-library-selector';
|
||||
import { byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -8,7 +8,7 @@ import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
|
||||
import RuleList from './RuleList.v2';
|
||||
import RuleList, { RuleListActions } from './RuleList.v2';
|
||||
|
||||
// This tests only checks if proper components are rendered, so we mock them
|
||||
// Both FilterView and GroupedView are tested in their own tests
|
||||
@@ -68,3 +68,95 @@ describe('RuleList v2', () => {
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleListActions', () => {
|
||||
const ui = {
|
||||
newRuleButton: byRole('link', { name: /new alert rule/i }),
|
||||
moreButton: byRole('button', { name: /more/i }),
|
||||
moreMenu: byRole('menu'),
|
||||
menuOptions: {
|
||||
draftNewRule: byRole('link', { name: /draft a new rule/i }),
|
||||
newGrafanaRecordingRule: byRole('link', { name: /new grafana recording rule/i }),
|
||||
newDataSourceRecordingRule: byRole('link', { name: /new data source recording rule/i }),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ permissions: [AccessControlAction.AlertingRuleCreate] },
|
||||
{ permissions: [AccessControlAction.AlertingRuleExternalWrite] },
|
||||
{ permissions: [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite] },
|
||||
])('should show "New alert rule" button when the user has $permissions permissions', ({ permissions }) => {
|
||||
grantUserPermissions(permissions);
|
||||
|
||||
render(<RuleListActions />);
|
||||
|
||||
expect(ui.newRuleButton.get()).toBeInTheDocument();
|
||||
expect(ui.moreButton.get()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "New alert rule" button when user has no permissions to create rules', () => {
|
||||
grantUserPermissions([]);
|
||||
|
||||
render(<RuleListActions />);
|
||||
|
||||
expect(ui.newRuleButton.query()).not.toBeInTheDocument();
|
||||
expect(ui.moreButton.get()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only show Draft a new rule when the user has view Grafana rules permission', async () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
|
||||
|
||||
const { user } = render(<RuleListActions />);
|
||||
|
||||
await user.click(ui.moreButton.get());
|
||||
const menu = await ui.moreMenu.find();
|
||||
|
||||
expect(ui.newRuleButton.query()).not.toBeInTheDocument();
|
||||
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).not.toBeInTheDocument();
|
||||
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "New Grafana recording rule" option when user has Grafana rule permissions', async () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
|
||||
|
||||
const { user } = render(<RuleListActions />);
|
||||
|
||||
await user.click(ui.moreButton.get());
|
||||
const menu = await ui.moreMenu.find();
|
||||
|
||||
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "New Data source recording rule" option when user has external rule permissions', async () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]);
|
||||
|
||||
const { user } = render(<RuleListActions />);
|
||||
|
||||
await user.click(ui.moreButton.get());
|
||||
const menu = await ui.moreMenu.find();
|
||||
|
||||
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).not.toBeInTheDocument();
|
||||
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show both recording rule options when user has all permissions', async () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]);
|
||||
|
||||
const { user } = render(<RuleListActions />);
|
||||
|
||||
await user.click(ui.moreButton.get());
|
||||
const menu = await ui.moreMenu.find();
|
||||
|
||||
expect(ui.menuOptions.draftNewRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).toBeInTheDocument();
|
||||
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import RulesFilter from '../components/rules/Filter/RulesFilter';
|
||||
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
|
||||
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
||||
import { useRulesFilter } from '../hooks/useFilteredRules';
|
||||
import { useURLSearchParams } from '../hooks/useURLSearchParams';
|
||||
|
||||
@@ -22,9 +28,65 @@ function RuleList() {
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleListActions() {
|
||||
const [createGrafanaRuleSupported, createGrafanaRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
|
||||
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
|
||||
|
||||
const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed;
|
||||
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
|
||||
|
||||
const canCreateRules = canCreateGrafanaRules || canCreateCloudRules;
|
||||
|
||||
const moreActionsMenu = useMemo(
|
||||
() => (
|
||||
<Menu>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={t('alerting.rule-list.draft-new-rule', 'Draft a new rule')}
|
||||
icon="file-export"
|
||||
url="/alerting/export-new-rule"
|
||||
/>
|
||||
</Menu.Group>
|
||||
<Menu.Group label={t('alerting.rule-list.recording-rules', 'Recording rules')}>
|
||||
{canCreateGrafanaRules && (
|
||||
<Menu.Item
|
||||
label={t('alerting.rule-list.new-grafana-recording-rule', 'New Grafana recording rule')}
|
||||
icon="grafana"
|
||||
url="/alerting/new/grafana-recording"
|
||||
/>
|
||||
)}
|
||||
{canCreateCloudRules && (
|
||||
<Menu.Item
|
||||
label={t('alerting.rule-list.new-datasource-recording-rule', 'New Data source recording rule')}
|
||||
icon="gf-prometheus"
|
||||
url="/alerting/new/recording"
|
||||
/>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</Menu>
|
||||
),
|
||||
[canCreateGrafanaRules, canCreateCloudRules]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
{canCreateRules && (
|
||||
<LinkButton variant="primary" icon="plus" href="/alerting/new/alerting">
|
||||
<Trans i18nKey="alerting.rule-list.new-alert-rule">New alert rule</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
<Dropdown overlay={moreActionsMenu}>
|
||||
<Button variant="secondary">
|
||||
<Trans i18nKey="alerting.rule-list.more">More</Trans> <Icon name="angle-down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RuleListPage() {
|
||||
return (
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={<RuleListActions />}>
|
||||
<RuleList />
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { ReactNode, useEffect, useId } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
import { Labels, PromAlertingRuleState, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { logError } from '../../Analytics';
|
||||
import { MetaText } from '../../components/MetaText';
|
||||
@@ -20,10 +20,10 @@ import { RulePluginOrigin } from '../../utils/rules';
|
||||
|
||||
import { ListItem } from './ListItem';
|
||||
import { DataSourceIcon } from './Namespace';
|
||||
import { RuleListIcon } from './RuleListIcon';
|
||||
import { RuleListIcon, RuleOperation } from './RuleListIcon';
|
||||
import { calculateNextEvaluationEstimate } from './util';
|
||||
|
||||
interface AlertRuleListItemProps {
|
||||
export interface AlertRuleListItemProps {
|
||||
name: string;
|
||||
href: string;
|
||||
summary?: string;
|
||||
@@ -44,6 +44,7 @@ interface AlertRuleListItemProps {
|
||||
contactPoint?: string;
|
||||
actions?: ReactNode;
|
||||
origin?: RulePluginOrigin;
|
||||
operation?: RuleOperation;
|
||||
}
|
||||
|
||||
export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
@@ -67,8 +68,11 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
labels,
|
||||
origin,
|
||||
actions = null,
|
||||
operation,
|
||||
} = props;
|
||||
|
||||
const listItemAriaId = useId();
|
||||
|
||||
const metadata: ReactNode[] = [];
|
||||
if (namespace && group) {
|
||||
metadata.push(
|
||||
@@ -124,9 +128,10 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
aria-labelledby={listItemAriaId}
|
||||
title={
|
||||
<Stack direction="row" alignItems="center">
|
||||
<TextLink href={href} inline={false}>
|
||||
<TextLink href={href} color="primary" inline={false} id={listItemAriaId}>
|
||||
{name}
|
||||
</TextLink>
|
||||
{origin && <PluginOriginBadge pluginId={origin.pluginId} size="sm" />}
|
||||
@@ -137,14 +142,17 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
</Stack>
|
||||
}
|
||||
description={<Summary content={summary} error={error} />}
|
||||
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} />}
|
||||
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} operation={operation} />}
|
||||
actions={actions}
|
||||
meta={metadata}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type RecordingRuleListItemProps = Omit<AlertRuleListItemProps, 'summary' | 'state' | 'instancesCount' | 'contactPoint'>;
|
||||
export type RecordingRuleListItemProps = Omit<
|
||||
AlertRuleListItemProps,
|
||||
'summary' | 'state' | 'instancesCount' | 'contactPoint'
|
||||
>;
|
||||
|
||||
export function RecordingRuleListItem({
|
||||
name,
|
||||
@@ -191,6 +199,48 @@ export function RecordingRuleListItem({
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleOperationListItemProps {
|
||||
name: string;
|
||||
namespace: string;
|
||||
group: string;
|
||||
rulesSource?: RulesSourceIdentifier;
|
||||
application?: RulesSourceApplication;
|
||||
operation: RuleOperation;
|
||||
}
|
||||
|
||||
export function RuleOperationListItem({
|
||||
name,
|
||||
namespace,
|
||||
group,
|
||||
rulesSource,
|
||||
application,
|
||||
operation,
|
||||
}: RuleOperationListItemProps) {
|
||||
const listItemAriaId = useId();
|
||||
|
||||
const metadata: ReactNode[] = [];
|
||||
if (namespace && group) {
|
||||
metadata.push(
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
aria-labelledby={listItemAriaId}
|
||||
title={
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Text id={listItemAriaId}>{name}</Text>
|
||||
</Stack>
|
||||
}
|
||||
icon={<RuleListIcon operation={operation} />}
|
||||
meta={metadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryProps {
|
||||
content?: string;
|
||||
error?: string;
|
||||
@@ -255,23 +305,24 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu
|
||||
}
|
||||
|
||||
interface UnknownRuleListItemProps {
|
||||
rule: Rule;
|
||||
ruleName: string;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
ruleDefinition: Rule | RulerRuleDTO;
|
||||
}
|
||||
|
||||
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
|
||||
export const UnknownRuleListItem = ({ ruleName, groupIdentifier, ruleDefinition }: UnknownRuleListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
const { namespace, groupName } = groupIdentifier;
|
||||
const ruleContext = {
|
||||
name: rule.name,
|
||||
name: ruleName,
|
||||
groupName,
|
||||
namespace: JSON.stringify(namespace),
|
||||
rulesSource: getGroupOriginName(groupIdentifier),
|
||||
};
|
||||
logError(new Error('unknown rule type'), ruleContext);
|
||||
}, [rule, groupIdentifier]);
|
||||
}, [ruleName, groupIdentifier]);
|
||||
|
||||
return (
|
||||
<Alert title={'Unknown rule type'} className={styles.resetMargin}>
|
||||
@@ -280,7 +331,7 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
|
||||
<Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans>
|
||||
</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(rule, null, 2)}</code>
|
||||
<code>{JSON.stringify(ruleDefinition, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</Alert>
|
||||
@@ -332,3 +383,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
margin: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export type RuleListItemCommonProps = Pick<
|
||||
AlertRuleListItemProps,
|
||||
Extract<keyof AlertRuleListItemProps, keyof RecordingRuleListItemProps>
|
||||
>;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { ListItem } from './ListItem';
|
||||
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
|
||||
import { RuleListIcon } from './RuleListIcon';
|
||||
|
||||
export function AlertRuleListItemLoader() {
|
||||
export function AlertRuleListItemSkeleton() {
|
||||
return (
|
||||
<ListItem
|
||||
title={<Skeleton width={64} />}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface GroupStatusProps {
|
||||
status: 'deleting'; // We don't support other statuses yet
|
||||
}
|
||||
|
||||
export function GroupStatus({ status }: GroupStatusProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loader} />
|
||||
{status === 'deleting' && (
|
||||
<Tooltip content="The group is being deleted">
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon name="trash-alt" size="sm" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rotation = keyframes({
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: theme.spacing(0.5),
|
||||
}),
|
||||
|
||||
loader: css({
|
||||
position: 'absolute',
|
||||
inset: `-${theme.spacing(0.5)}`,
|
||||
border: '2px solid #FFF',
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
boxSizing: 'border-box',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
animationName: rotation,
|
||||
animationIterationCount: 'infinite',
|
||||
animationDuration: '1s',
|
||||
animationTimingFunction: 'linear',
|
||||
},
|
||||
|
||||
'&::after': {
|
||||
content: '""',
|
||||
boxSizing: 'border-box',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'calc(100% + 4px)',
|
||||
height: 'calc(100% + 4px)',
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
border: '2px solid transparent',
|
||||
borderBottomColor: theme.colors.action.selectedBorder,
|
||||
},
|
||||
}),
|
||||
|
||||
iconWrapper: css({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
}),
|
||||
|
||||
'@keyframes rotation': {
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { IconButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { Spacer } from '../../components/Spacer';
|
||||
@@ -14,6 +14,7 @@ interface GroupProps extends PropsWithChildren {
|
||||
metaRight?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
isOpen?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const ListGroup = ({
|
||||
@@ -22,6 +23,7 @@ export const ListGroup = ({
|
||||
isOpen = true,
|
||||
metaRight = null,
|
||||
actions = null,
|
||||
href,
|
||||
children,
|
||||
}: GroupProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -36,6 +38,7 @@ export const ListGroup = ({
|
||||
name={name}
|
||||
metaRight={metaRight}
|
||||
actions={actions}
|
||||
href={href}
|
||||
/>
|
||||
{open && <div role="group">{children}</div>}
|
||||
</div>
|
||||
@@ -47,7 +50,7 @@ type GroupHeaderProps = GroupProps & {
|
||||
};
|
||||
|
||||
const GroupHeader = (props: GroupHeaderProps) => {
|
||||
const { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props;
|
||||
const { name, description, metaRight = null, actions = null, isOpen = false, onToggle, href } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@@ -60,9 +63,15 @@ const GroupHeader = (props: GroupHeaderProps) => {
|
||||
onClick={onToggle}
|
||||
aria-label={t('common.collapse', 'Collapse')}
|
||||
/>
|
||||
<Text truncate variant="body" element="h4">
|
||||
{name}
|
||||
</Text>
|
||||
{href ? (
|
||||
<TextLink href={href} color="primary" inline={false}>
|
||||
{name}
|
||||
</TextLink>
|
||||
) : (
|
||||
<Text truncate variant="body" element="h4">
|
||||
{name}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{description}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { AriaAttributes, ReactNode } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface ListItemProps {
|
||||
interface ListItemProps extends AriaAttributes {
|
||||
icon?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
@@ -17,10 +17,16 @@ interface ListItemProps {
|
||||
|
||||
export const ListItem = (props: ListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId } = props;
|
||||
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId, ...ariaAttributes } = props;
|
||||
|
||||
return (
|
||||
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false" data-testid={testId}>
|
||||
<li
|
||||
className={styles.alertListItemContainer}
|
||||
role="treeitem"
|
||||
aria-selected="false"
|
||||
data-testid={testId}
|
||||
{...ariaAttributes}
|
||||
>
|
||||
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
|
||||
{/* icon */}
|
||||
{icon}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRelativeUrl } from '../../utils/url';
|
||||
|
||||
interface Props {
|
||||
rule: RulerRuleDTO;
|
||||
promRule: Rule;
|
||||
promRule?: Rule;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
/**
|
||||
* Should we show the buttons in a "compact" state?
|
||||
|
||||
+142
-7
@@ -1,16 +1,120 @@
|
||||
import { Dropdown, IconButton, Menu } from '@grafana/ui';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { Dropdown, Icon, IconButton, LinkButton, Menu } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { DataSourceRuleGroupIdentifier, GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
|
||||
import { useFolder } from '../../hooks/useFolder';
|
||||
import { useRulesAccess } from '../../utils/accessControlHooks';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSourceByUID } from '../../utils/datasource';
|
||||
import { groups } from '../../utils/navigation';
|
||||
import { isFederatedRuleGroup, isPluginProvidedGroup, isProvisionedRuleGroup } from '../../utils/rules';
|
||||
|
||||
import { GroupStatus } from './GroupStatus';
|
||||
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
|
||||
|
||||
const { useGetGrafanaRulerGroupQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
interface DataSourceGroupsActionMenuProps {
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
}
|
||||
|
||||
interface GrafanaGroupsActionMenuProps {
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
}
|
||||
|
||||
type RuleGroupActionsMenuProps = DataSourceGroupsActionMenuProps | GrafanaGroupsActionMenuProps;
|
||||
|
||||
export function RuleGroupActionsMenu({ groupIdentifier }: RuleGroupActionsMenuProps) {
|
||||
switch (groupIdentifier.groupOrigin) {
|
||||
case 'grafana':
|
||||
return <GrafanaGroupsActionMenu groupIdentifier={groupIdentifier} />;
|
||||
case 'datasource':
|
||||
return <DataSourceGroupsActionMenu groupIdentifier={groupIdentifier} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DataSourceGroupsActionMenu({ groupIdentifier }: DataSourceGroupsActionMenuProps) {
|
||||
const { canEditRules } = useRulesAccess();
|
||||
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: groupIdentifier.rulesSource.uid });
|
||||
|
||||
const {
|
||||
data: rulerRuleGroup,
|
||||
error: rulerGroupError,
|
||||
isLoading: isRulerGroupLoading,
|
||||
} = useGetRuleGroupForNamespaceQuery(
|
||||
dataSourceInfo?.rulerConfig
|
||||
? {
|
||||
namespace: groupIdentifier.namespace.name,
|
||||
group: groupIdentifier.groupName,
|
||||
rulerConfig: dataSourceInfo?.rulerConfig!,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const isFederated = rulerRuleGroup ? isFederatedRuleGroup(rulerRuleGroup) : false;
|
||||
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
|
||||
|
||||
const canEdit = !isFederated && !isPluginProvided && canEditRules(groupIdentifier.rulesSource.name);
|
||||
const rulesSource = getRulesDataSourceByUID(groupIdentifier.rulesSource.uid);
|
||||
|
||||
if (!rulesSource) {
|
||||
// This should never happen
|
||||
return null;
|
||||
}
|
||||
|
||||
// We don't provide any actions if the data source doesn't support ruler
|
||||
if (!dataSourceInfo?.rulerConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRulerGroupLoading) {
|
||||
return <RuleActionsSkeleton />;
|
||||
}
|
||||
|
||||
if (rulerGroupError) {
|
||||
if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) {
|
||||
return <GroupStatus status="deleting" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
name="exclamation-triangle"
|
||||
title={t('alerting.group-actions-menu.group-load-failed', 'Failed to load group details')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never happen. Loading and error states are handled above
|
||||
if (!rulerRuleGroup) {
|
||||
return <Icon name="exclamation-triangle" title={t('alerting.group-actions-menu.unknown-error', 'Unknown error')} />;
|
||||
}
|
||||
|
||||
export function RuleGroupActionsMenu() {
|
||||
return (
|
||||
<Dropdown
|
||||
placement="right-start"
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label={t('alerting.group-actions.edit', 'Edit')} icon="pen" data-testid="edit-group-action" />
|
||||
<Menu.Item label={t('alerting.group-actions.reorder', 'Re-order rules')} icon="flip" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label={t('alerting.group-actions.export', 'Export')} icon="download-alt" />
|
||||
<Menu.Item label={t('alerting.group-actions.delete', 'Delete')} icon="trash-alt" destructive />
|
||||
<Menu.Item
|
||||
label={t('alerting.group-actions.details', 'Details')}
|
||||
icon="info-circle"
|
||||
data-testid="details-group-action"
|
||||
url={groups.detailsPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
|
||||
/>
|
||||
{canEdit && (
|
||||
<Menu.Item
|
||||
label={t('alerting.group-actions.edit', 'Edit')}
|
||||
icon="pen"
|
||||
data-testid="edit-group-action"
|
||||
url={groups.editPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
@@ -18,3 +122,34 @@ export function RuleGroupActionsMenu() {
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function GrafanaGroupsActionMenu({ groupIdentifier }: GrafanaGroupsActionMenuProps) {
|
||||
const { canEditRules } = useRulesAccess();
|
||||
const { data: rulerRuleGroup } = useGetGrafanaRulerGroupQuery({
|
||||
folderUid: groupIdentifier.namespace.uid,
|
||||
groupName: groupIdentifier.groupName,
|
||||
});
|
||||
|
||||
const isProvisioned = rulerRuleGroup ? isProvisionedRuleGroup(rulerRuleGroup) : false;
|
||||
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
|
||||
|
||||
const folderUid = groupIdentifier.namespace.uid;
|
||||
const { folder } = useFolder(folderUid);
|
||||
|
||||
const canEdit = folder?.canSave && !isProvisioned && !isPluginProvided && canEditRules(GRAFANA_RULES_SOURCE_NAME);
|
||||
|
||||
if (!canEdit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
icon="pen"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={groups.editPageLink(GRAFANA_RULES_SOURCE_NAME, folderUid, groupIdentifier.groupName)}
|
||||
>
|
||||
{t('alerting.group-actions.edit', 'Edit')}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
import { ComponentProps, memo } from 'react';
|
||||
import type { RequireAtLeastOne } from 'type-fest';
|
||||
|
||||
import { Icon, type IconName, Text, Tooltip } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, type IconName, Text, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import type { RuleHealth } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@@ -14,6 +16,12 @@ interface RuleListIconProps {
|
||||
state?: PromAlertingRuleState;
|
||||
health?: RuleHealth;
|
||||
isPaused?: boolean;
|
||||
operation?: RuleOperation;
|
||||
}
|
||||
|
||||
export enum RuleOperation {
|
||||
Creating = 'Creating',
|
||||
Deleting = 'Deleting',
|
||||
}
|
||||
|
||||
const icons: Record<PromAlertingRuleState, IconName> = {
|
||||
@@ -34,6 +42,14 @@ const stateNames: Record<PromAlertingRuleState, string> = {
|
||||
[PromAlertingRuleState.Firing]: 'Firing',
|
||||
};
|
||||
|
||||
const operationIcons: Record<RuleOperation, IconName> = {
|
||||
[RuleOperation.Creating]: 'plus-circle',
|
||||
[RuleOperation.Deleting]: 'minus-circle',
|
||||
};
|
||||
|
||||
// ⚠️ not trivial to update this, you have to re-do the math for the loading spinner
|
||||
const ICON_SIZE = 18;
|
||||
|
||||
/**
|
||||
* Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view
|
||||
* This component is often rendered tens or hundreds of times in a single page, so it's performance is important
|
||||
@@ -43,7 +59,11 @@ export const RuleListIcon = memo(function RuleListIcon({
|
||||
health,
|
||||
recording = false,
|
||||
isPaused = false,
|
||||
operation,
|
||||
}: RequireAtLeastOne<RuleListIconProps>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
|
||||
let iconName: IconName = state ? icons[state] : 'circle';
|
||||
let iconColor: TextProps['color'] = state ? color[state] : 'secondary';
|
||||
let stateName: string = state ? stateNames[state] : 'unknown';
|
||||
@@ -72,13 +92,79 @@ export const RuleListIcon = memo(function RuleListIcon({
|
||||
stateName = 'Paused';
|
||||
}
|
||||
|
||||
if (operation) {
|
||||
iconName = operationIcons[operation];
|
||||
iconColor = 'secondary';
|
||||
stateName = operation;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={stateName} placement="right">
|
||||
<div>
|
||||
<Text color={iconColor}>
|
||||
<Icon name={iconName} size="lg" />
|
||||
<div className={styles.iconsContainer}>
|
||||
<Icon name={iconName} width={18} height={18} title={stateName} />
|
||||
{/* this loading spinner works by using an optical illusion;
|
||||
the actual icon is static and the "spinning" part is just a semi-transparent darker circle overlayed on top.
|
||||
This makes it look like there is a small bright colored spinner rotating.
|
||||
*/}
|
||||
{operation && (
|
||||
<svg
|
||||
width={ICON_SIZE}
|
||||
height={ICON_SIZE}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={styles.spinning}
|
||||
>
|
||||
<circle
|
||||
r={ICON_SIZE / 2}
|
||||
cx="12"
|
||||
cy="12"
|
||||
// make sure to match this color to the color of the list item background where it's being used! Works for both light and dark themes.
|
||||
stroke={theme.colors.background.primary}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
strokeOpacity={0.85}
|
||||
strokeDasharray="24px"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const spin = keyframes({
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'50%': {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
iconsContainer: css({
|
||||
position: 'relative',
|
||||
width: 18,
|
||||
height: 18,
|
||||
'> *': {
|
||||
position: 'absolute',
|
||||
},
|
||||
}),
|
||||
spinning: css({
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
animationName: spin,
|
||||
animationIterationCount: 'infinite',
|
||||
animationDuration: '1s',
|
||||
animationTimingFunction: 'linear',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,41 +1,97 @@
|
||||
import { BaseQueryFn } from '@reduxjs/toolkit/query';
|
||||
import { TypedLazyQueryTrigger } from '@reduxjs/toolkit/query/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useDispatch } from 'app/types/store';
|
||||
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { BaseQueryFnArgs } from '../../api/alertingApi';
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
|
||||
|
||||
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
|
||||
|
||||
interface UseGeneratorHookOptions {
|
||||
populateCache?: boolean;
|
||||
}
|
||||
|
||||
interface FetchGroupsOptions {
|
||||
groupLimit?: number;
|
||||
groupNextToken?: string;
|
||||
}
|
||||
|
||||
export function usePrometheusGroupsGenerator() {
|
||||
export function usePrometheusGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
|
||||
const dispatch = useDispatch();
|
||||
const [getGroups] = useLazyGetGroupsQuery();
|
||||
|
||||
return useCallback(
|
||||
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
|
||||
const getRuleSourceGroups = (options: FetchGroupsOptions) =>
|
||||
getGroups({ ruleSource: { uid: ruleSource.uid }, ...options });
|
||||
const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => {
|
||||
const response = await getGroups({
|
||||
ruleSource: { uid: ruleSource.uid },
|
||||
notificationOptions: { showErrorAlert: false },
|
||||
...fetchOptions,
|
||||
}).unwrap();
|
||||
|
||||
yield* genericGroupsGenerator(getRuleSourceGroups, groupLimit);
|
||||
if (hookOptions.populateCache) {
|
||||
response.data.groups.forEach((group) => {
|
||||
dispatch(
|
||||
prometheusApi.util.upsertQueryData(
|
||||
'getGroups',
|
||||
{ ruleSource: { uid: ruleSource.uid }, namespace: group.file, groupName: group.name },
|
||||
{ data: { groups: [group] }, status: 'success' }
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit);
|
||||
},
|
||||
[getGroups]
|
||||
[getGroups, dispatch, hookOptions.populateCache]
|
||||
);
|
||||
}
|
||||
|
||||
export function useGrafanaGroupsGenerator() {
|
||||
export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
|
||||
const dispatch = useDispatch();
|
||||
const [getGrafanaGroups] = useLazyGetGrafanaGroupsQuery();
|
||||
|
||||
const getGroupsAndProvideCache = useCallback(
|
||||
async (fetchOptions: FetchGroupsOptions) => {
|
||||
const response = await getGrafanaGroups(fetchOptions).unwrap();
|
||||
|
||||
// This is not mandatory to preload ruler rules, but it improves the UX
|
||||
// Because the user waits a bit longer for the initial load but doesn't need to wait for each group to be loaded
|
||||
if (hookOptions.populateCache) {
|
||||
const cacheAndRulerPreload = response.data.groups.map(async (group) => {
|
||||
dispatch(
|
||||
alertRuleApi.util.prefetch(
|
||||
'getGrafanaRulerGroup',
|
||||
{ folderUid: group.folderUid, groupName: group.name },
|
||||
{ force: true }
|
||||
)
|
||||
);
|
||||
await dispatch(
|
||||
prometheusApi.util.upsertQueryData(
|
||||
'getGrafanaGroups',
|
||||
{ folderUid: group.folderUid, groupName: group.name },
|
||||
{ data: { groups: [group] }, status: 'success' }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(cacheAndRulerPreload);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[getGrafanaGroups, dispatch, hookOptions.populateCache]
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async function* (groupLimit: number) {
|
||||
yield* genericGroupsGenerator(getGrafanaGroups, groupLimit);
|
||||
yield* genericGroupsGenerator(getGroupsAndProvideCache, groupLimit);
|
||||
},
|
||||
[getGrafanaGroups]
|
||||
[getGroupsAndProvideCache]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,38 +100,18 @@ export function useGrafanaGroupsGenerator() {
|
||||
// For unpaginated data sources we fetch everything in one go
|
||||
// For paginated we fetch the next page when needed
|
||||
async function* genericGroupsGenerator<TGroup>(
|
||||
fetchGroups: TypedLazyQueryTrigger<PromRulesResponse<TGroup>, FetchGroupsOptions, BaseQueryFn<BaseQueryFnArgs>>,
|
||||
fetchGroups: (options: FetchGroupsOptions) => Promise<PromRulesResponse<TGroup>>,
|
||||
groupLimit: number
|
||||
) {
|
||||
const response = await fetchGroups({ groupLimit });
|
||||
let response = await fetchGroups({ groupLimit });
|
||||
yield* response.data.groups;
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
let lastToken: string | undefined = response.data?.groupNextToken;
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: groupLimit,
|
||||
});
|
||||
response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit });
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
yield* response.data.groups;
|
||||
lastToken = response.data?.groupNextToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AsyncIterableX, empty, from } from 'ix/asynciterable';
|
||||
import { merge } from 'ix/asynciterable/merge';
|
||||
import { filter, flatMap, map } from 'ix/asynciterable/operators';
|
||||
import { catchError, filter, flatMap, map } from 'ix/asynciterable/operators';
|
||||
import { compact } from 'lodash';
|
||||
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
@@ -65,12 +65,16 @@ export function useFilteredRulesIteratorProvider() {
|
||||
filter((group) => groupFilter(group, normalizedFilterState)),
|
||||
flatMap((group) => group.rules.map((rule) => [group, rule] as const)),
|
||||
filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)),
|
||||
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule))
|
||||
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)),
|
||||
catchError(() => empty())
|
||||
);
|
||||
|
||||
const sourceIterables = ruleSourcesToFetchFrom.map((ds) => {
|
||||
const generator = prometheusGroupsGenerator(ds, groupLimit);
|
||||
return from(generator).pipe(map((group) => [ds, group] as const));
|
||||
return from(generator).pipe(
|
||||
map((group) => [ds, group] as const),
|
||||
catchError(() => empty())
|
||||
);
|
||||
});
|
||||
|
||||
// if we have no prometheus data sources, use an empty async iterable
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
|
||||
import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching';
|
||||
|
||||
describe('getMatchingRulerRule', () => {
|
||||
it('should match rule by unique name', () => {
|
||||
// Create a ruler rule group with a single rule
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' });
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule] });
|
||||
|
||||
// Create a matching prom rule with same name
|
||||
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' });
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
expect(match).toBe(rulerRule);
|
||||
});
|
||||
|
||||
it('should not match when names are different', () => {
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'test-rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule] });
|
||||
|
||||
// Create a prom rule with different name but same labels/annotations
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'test-rule-2',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should match by labels and annotations when multiple rules have same name', () => {
|
||||
// Create two ruler rules with same name but different labels
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'different' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create a matching prom rule with same name and matching labels
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
expect(match).toBe(rulerRule1);
|
||||
});
|
||||
|
||||
it('should match by query when multiple rules have same name and labels', () => {
|
||||
// Create two ruler rules with same name and labels but different queries
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
expr: 'up == 1',
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
expr: 'up == 0',
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create a matching prom rule with same name, labels, and query
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
query: 'up == 1',
|
||||
});
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
expect(match).toBe(rulerRule1);
|
||||
});
|
||||
|
||||
it('should return undefined when rules differ only in the query part', () => {
|
||||
// Create two ruler rules with same name but different labels and queries
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
expr: 'up == 1',
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'different' },
|
||||
expr: 'up == 0',
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create a prom rule with same name but non-matching labels and query
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'error' },
|
||||
annotations: { summary: 'other' },
|
||||
query: 'up == 2',
|
||||
});
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingPromRule', () => {
|
||||
it('should match rule by unique name', () => {
|
||||
// Create a prom rule group with a single rule
|
||||
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' });
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule] });
|
||||
|
||||
// Create a matching ruler rule with same name
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' });
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
expect(match).toBe(promRule);
|
||||
});
|
||||
|
||||
it('should not match when names are different', () => {
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'test-rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule] });
|
||||
|
||||
// Create a ruler rule with different name but same labels/annotations
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'test-rule-2',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should match by labels and annotations when multiple rules have same name', () => {
|
||||
// Create two prom rules with same name but different labels
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'different' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
// Create a matching ruler rule with same name and matching labels
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
expect(match).toBe(promRule1);
|
||||
});
|
||||
|
||||
it('should match by query when multiple rules have same name and labels', () => {
|
||||
// Create two prom rules with same name and labels but different queries
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
query: 'up == 1',
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
query: 'up == 0',
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
// Create a matching ruler rule with same name, labels, and expression
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
expr: 'up == 1',
|
||||
});
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
expect(match).toBe(promRule1);
|
||||
});
|
||||
|
||||
it('should return undefined when rules differ only in the query part', () => {
|
||||
// Create two prom rules with same name but different labels and queries
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
query: 'up == 1',
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'same-name',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'different' },
|
||||
query: 'up == 0',
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
// Create a ruler rule with same name but non-matching labels and expression
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'same-name',
|
||||
labels: { severity: 'error' },
|
||||
annotations: { summary: 'other' },
|
||||
expr: 'up == 2',
|
||||
});
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchRulesGroup', () => {
|
||||
it('should match all rules when both groups have the same rules', () => {
|
||||
// Create ruler rules
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create matching prom rules
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup);
|
||||
|
||||
// All rules should be matched
|
||||
expect(result.matches.size).toBe(2);
|
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1);
|
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2);
|
||||
expect(result.promOnlyRules).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle ruler group having more rules than prom group', () => {
|
||||
// Create ruler rules (3 rules)
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const rulerRule3 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-3',
|
||||
labels: { severity: 'error' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] });
|
||||
|
||||
// Create matching prom rules (only 2 rules)
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup);
|
||||
|
||||
// Only 2 rules should be matched
|
||||
expect(result.matches.size).toBe(2);
|
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1);
|
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2);
|
||||
expect(result.matches.get(rulerRule3)).toBeUndefined();
|
||||
expect(result.promOnlyRules).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle prom group having more rules than ruler group', () => {
|
||||
// Create ruler rules (2 rules)
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create matching prom rules (3 rules)
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-1',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-2',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const promRule3 = alertingFactory.prometheus.rule.build({
|
||||
name: 'rule-3',
|
||||
labels: { severity: 'error' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2, promRule3] });
|
||||
|
||||
const result = matchRulesGroup(rulerGroup, promGroup);
|
||||
|
||||
// 2 rules should be matched, 1 should be in promOnlyRules
|
||||
expect(result.matches.size).toBe(2);
|
||||
expect(result.matches.get(rulerRule1)).toBe(promRule1);
|
||||
expect(result.matches.get(rulerRule2)).toBe(promRule2);
|
||||
expect(result.promOnlyRules).toHaveLength(1);
|
||||
expect(result.promOnlyRules[0]).toBe(promRule3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Rule } from 'app/types/unified-alerting';
|
||||
import {
|
||||
PromRuleDTO,
|
||||
PromRuleGroupDTO,
|
||||
RulerCloudRuleDTO,
|
||||
RulerRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getPromRuleFingerprint, getRulerRuleFingerprint } from '../utils/rule-id';
|
||||
import { getRuleName } from '../utils/rules';
|
||||
|
||||
export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>, rule: Rule) {
|
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = rulerRuleGroup.rules.filter((r) => getRuleName(r) === rule.name);
|
||||
if (rulesByName.length === 1) {
|
||||
return rulesByName[0];
|
||||
}
|
||||
|
||||
// If we don't have a unique rule name, try to compare by labels and annotations
|
||||
const rulesByLabelsAndAnnotations = rulesByName.filter((r) => {
|
||||
return getRulerRuleFingerprint(r, false).join('-') === getPromRuleFingerprint(rule, false).join('-');
|
||||
});
|
||||
|
||||
if (rulesByLabelsAndAnnotations.length === 1) {
|
||||
return rulesByLabelsAndAnnotations[0];
|
||||
}
|
||||
|
||||
// As a last resort, compare including the query
|
||||
const rulesByLabelsAndAnnotationsAndQuery = rulesByName.filter((r) => {
|
||||
return getRulerRuleFingerprint(r, true).join('-') === getPromRuleFingerprint(rule, true).join('-');
|
||||
});
|
||||
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length === 1) {
|
||||
return rulesByLabelsAndAnnotationsAndQuery[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO<PromRuleDTO>, rule: RulerCloudRuleDTO) {
|
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = promRuleGroup.rules.filter((r) => r.name === getRuleName(rule));
|
||||
if (rulesByName.length === 1) {
|
||||
return rulesByName[0];
|
||||
}
|
||||
|
||||
// If we don't have a unique rule name, try to compare by labels and annotations
|
||||
const rulesByLabelsAndAnnotations = rulesByName.filter((r) => {
|
||||
return getPromRuleFingerprint(r, false).join('-') === getRulerRuleFingerprint(rule, false).join('-');
|
||||
});
|
||||
|
||||
if (rulesByLabelsAndAnnotations.length === 1) {
|
||||
return rulesByLabelsAndAnnotations[0];
|
||||
}
|
||||
|
||||
// As a last resort, compare including the query
|
||||
const rulesByLabelsAndAnnotationsAndQuery = rulesByName.filter((r) => {
|
||||
return getPromRuleFingerprint(r, true).join('-') === getRulerRuleFingerprint(rule, true).join('-');
|
||||
});
|
||||
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length === 1) {
|
||||
return rulesByLabelsAndAnnotationsAndQuery[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface GroupMatchingResult {
|
||||
matches: Map<RulerRuleDTO, PromRuleDTO>;
|
||||
promOnlyRules: PromRuleDTO[];
|
||||
}
|
||||
|
||||
export function matchRulesGroup(
|
||||
rulerGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>,
|
||||
promGroup: PromRuleGroupDTO<PromRuleDTO>
|
||||
): GroupMatchingResult {
|
||||
const matchingResult = rulerGroup.rules.reduce(
|
||||
(acc, rulerRule) => {
|
||||
const { matches, unmatchedPromRules } = acc;
|
||||
|
||||
const promRule = getMatchingPromRule(promGroup, rulerRule);
|
||||
if (promRule) {
|
||||
matches.set(rulerRule, promRule);
|
||||
unmatchedPromRules.delete(promRule);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ matches: new Map<RulerRuleDTO, PromRuleDTO>(), unmatchedPromRules: new Set(promGroup.rules) }
|
||||
);
|
||||
|
||||
return { matches: matchingResult.matches, promOnlyRules: Array.from(matchingResult.unmatchedPromRules) };
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
DataSourceRulesSourceIdentifier as DataSourceRulesSourceIdentifier,
|
||||
GrafanaRulesSourceIdentifier,
|
||||
GrafanaRulesSourceSymbol,
|
||||
RuleIdentifier,
|
||||
RulesSource,
|
||||
RulesSourceIdentifier,
|
||||
RulesSourceUid,
|
||||
} from 'app/types/unified-alerting';
|
||||
|
||||
@@ -25,6 +27,7 @@ import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext';
|
||||
|
||||
import { instancesPermissions, notificationsPermissions, silencesPermissions } from './access-control';
|
||||
import { getAllDataSources } from './config';
|
||||
import { isGrafanaRuleIdentifier } from './rules';
|
||||
|
||||
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
|
||||
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
|
||||
@@ -341,3 +344,15 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti
|
||||
export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) {
|
||||
return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true
|
||||
}
|
||||
|
||||
export function ruleIdentifierToRuleSourceIdentifier(ruleIdentifier: RuleIdentifier): RulesSourceIdentifier {
|
||||
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||
return { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' };
|
||||
}
|
||||
|
||||
return {
|
||||
uid: getDatasourceAPIUid(ruleIdentifier.ruleSourceName),
|
||||
name: ruleIdentifier.ruleSourceName,
|
||||
ruleSourceType: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CombinedRule, RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import {
|
||||
CloudRuleIdentifier,
|
||||
CombinedRule,
|
||||
PrometheusRuleIdentifier,
|
||||
RuleGroupIdentifier,
|
||||
RuleGroupIdentifierV2,
|
||||
} from 'app/types/unified-alerting';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource';
|
||||
import { rulerRuleType } from './rules';
|
||||
@@ -37,6 +43,20 @@ export function ruleGroupIdentifierV2toV1(groupIdentifier: RuleGroupIdentifierV2
|
||||
};
|
||||
}
|
||||
|
||||
function fromRuleIdentifier(ruleIdentifier: PrometheusRuleIdentifier | CloudRuleIdentifier): RuleGroupIdentifierV2 {
|
||||
return {
|
||||
rulesSource: {
|
||||
ruleSourceType: 'datasource',
|
||||
name: ruleIdentifier.ruleSourceName,
|
||||
uid: getDatasourceAPIUid(ruleIdentifier.ruleSourceName),
|
||||
},
|
||||
namespace: { name: ruleIdentifier.namespace },
|
||||
groupName: ruleIdentifier.groupName,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
export const groupIdentifier = {
|
||||
fromCombinedRule,
|
||||
fromRuleIdentifier,
|
||||
};
|
||||
|
||||
@@ -33,16 +33,15 @@ import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
|
||||
import { getRulesSourceName } from './datasource';
|
||||
import { SupportedErrors, getErrorMessageFromCode, isApiMachineryError } from './k8s/errors';
|
||||
import { getMatcherQueryParams } from './matchers';
|
||||
import { rulesNav } from './navigation';
|
||||
import * as ruleId from './rule-id';
|
||||
import { createAbsoluteUrl, createRelativeUrl } from './url';
|
||||
|
||||
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo?: string): string {
|
||||
const sourceName = getRulesSourceName(ruleSource);
|
||||
const identifier = ruleId.fromCombinedRule(sourceName, rule);
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(sourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
return rulesNav.detailsPageLink(sourceName, identifier, returnTo ? { returnTo } : undefined);
|
||||
}
|
||||
|
||||
export function createViewLinkV2(
|
||||
@@ -52,10 +51,8 @@ export function createViewLinkV2(
|
||||
): string {
|
||||
const ruleSourceName = groupIdentifier.rulesSource.name;
|
||||
const identifier = ruleId.fromRule(ruleSourceName, groupIdentifier.namespace.name, groupIdentifier.groupName, rule);
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
return rulesNav.detailsPageLink(ruleSourceName, identifier, returnTo ? { returnTo } : undefined);
|
||||
}
|
||||
|
||||
export function createExploreLink(datasource: DataSourceRef, query: string) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { createReturnTo } from '../hooks/useReturnTo';
|
||||
|
||||
import { stringifyIdentifier } from './rule-id';
|
||||
import { createRelativeUrl } from './url';
|
||||
|
||||
type QueryParams = ConstructorParameters<typeof URLSearchParams>[0];
|
||||
|
||||
export const createListFilterLink = (values: Array<[string, string]>) => {
|
||||
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]);
|
||||
return createRelativeUrl(`/alerting/list`, params);
|
||||
@@ -43,3 +46,14 @@ export const groups = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const rulesNav = {
|
||||
/**
|
||||
* Creates a link to the details page of a rule. Encodes the rules source name and rule identifier.
|
||||
*/
|
||||
detailsPageLink: (rulesSourceName: string, ruleIdentifier: RuleIdentifier, params?: QueryParams) =>
|
||||
createRelativeUrl(
|
||||
`/alerting/${encodeURIComponent(rulesSourceName)}/${encodeURIComponent(stringifyIdentifier(ruleIdentifier))}/view`,
|
||||
params
|
||||
),
|
||||
};
|
||||
|
||||
@@ -262,16 +262,17 @@ export function hashRulerRule(rule: RulerRuleDTO): string {
|
||||
return rule.grafana_alert.uid;
|
||||
}
|
||||
|
||||
const fingerprint = getRulerRuleFingerprint(rule);
|
||||
return hash(JSON.stringify(fingerprint)).toString();
|
||||
}
|
||||
|
||||
function getRulerRuleFingerprint(rule: RulerCloudRuleDTO) {
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
// If the prometheusRulesPrimary feature toggle is enabled, we don't need to hash the query
|
||||
// We need to make fingerprint compatibility between Prometheus and Ruler rules
|
||||
// Query often differs between the two, so we can't use it to generate a fingerprint
|
||||
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.expr);
|
||||
const includeQuery = !prometheusRulesPrimary;
|
||||
const fingerprint = getRulerRuleFingerprint(rule, includeQuery);
|
||||
return hash(JSON.stringify(fingerprint)).toString();
|
||||
}
|
||||
|
||||
export function getRulerRuleFingerprint(rule: RulerCloudRuleDTO, includeQuery: boolean) {
|
||||
const queryHash = includeQuery ? hashQuery(rule.expr) : '';
|
||||
const labelsHash = hashLabelsOrAnnotations(rule.labels);
|
||||
|
||||
if (rulerRuleType.dataSource.recordingRule(rule)) {
|
||||
@@ -284,14 +285,15 @@ function getRulerRuleFingerprint(rule: RulerCloudRuleDTO) {
|
||||
}
|
||||
|
||||
export function hashRule(rule: Rule): string {
|
||||
const fingerprint = getPromRuleFingerprint(rule);
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
const includeQuery = !prometheusRulesPrimary;
|
||||
|
||||
const fingerprint = getPromRuleFingerprint(rule, includeQuery);
|
||||
return hash(JSON.stringify(fingerprint)).toString();
|
||||
}
|
||||
|
||||
function getPromRuleFingerprint(rule: Rule) {
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
|
||||
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.query);
|
||||
export function getPromRuleFingerprint(rule: Rule, includeQuery: boolean) {
|
||||
const queryHash = includeQuery ? hashQuery(rule.query) : '';
|
||||
const labelsHash = hashLabelsOrAnnotations(rule.labels);
|
||||
|
||||
if (prometheusRuleType.recordingRule(rule)) {
|
||||
|
||||
@@ -81,6 +81,11 @@ function isCloudRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerC
|
||||
function isCloudRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordingRuleDTO {
|
||||
return typeof rule === 'object' && 'record' in rule;
|
||||
}
|
||||
export function isCloudRulerGroup(
|
||||
rulerRuleGroup: RulerRuleGroupDTO
|
||||
): rulerRuleGroup is RulerRuleGroupDTO<RulerCloudRuleDTO> {
|
||||
return rulerRuleGroup.rules.every((r) => isCloudRulerRule(r));
|
||||
}
|
||||
|
||||
/* Prometheus rules */
|
||||
|
||||
@@ -224,6 +229,10 @@ function isPluginInstalled(pluginId: string) {
|
||||
return Boolean(config.apps[pluginId]);
|
||||
}
|
||||
|
||||
export function isPluginProvidedGroup(group: RulerRuleGroupDTO): boolean {
|
||||
return group.rules.some((rule) => isPluginProvidedRule(rule));
|
||||
}
|
||||
|
||||
export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean {
|
||||
return Boolean(getRulePluginOrigin(rule));
|
||||
}
|
||||
|
||||
@@ -503,6 +503,10 @@
|
||||
"recording": "Recording",
|
||||
"rule-name": "Rule name"
|
||||
},
|
||||
"ds-group-loader": {
|
||||
"group-deleting": "The group is being deleted",
|
||||
"group-load-failed": "Failed to load rules from group {{ groupName }} in {{ namespaceName }}"
|
||||
},
|
||||
"export": {
|
||||
"subtitle": {
|
||||
"formats": "Select the format and download the file or copy the contents to clipboard",
|
||||
@@ -521,10 +525,12 @@
|
||||
},
|
||||
"group-actions": {
|
||||
"actions-trigger": "Rule group actions",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"export": "Export",
|
||||
"reorder": "Re-order rules"
|
||||
"details": "Details",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"group-actions-menu": {
|
||||
"group-load-failed": "Failed to load group details",
|
||||
"unknown-error": "Unknown error"
|
||||
},
|
||||
"group-details": {
|
||||
"ds-features-error": "Error loading data source details",
|
||||
@@ -565,6 +571,9 @@
|
||||
"rule-group-error": "Error loading rule group",
|
||||
"title": "Edit evaluation group"
|
||||
},
|
||||
"group-loader": {
|
||||
"group-load-failed": "Failed to load rules from group {{ groupName }} in {{ namespaceName }}"
|
||||
},
|
||||
"irm-integration": {
|
||||
"connection-method": "How to connect to IRM",
|
||||
"disabled-description": "Enable Grafana IRM to use this integration",
|
||||
@@ -820,6 +829,7 @@
|
||||
},
|
||||
"rule-list": {
|
||||
"configure-datasource": "Configure",
|
||||
"draft-new-rule": "Draft a new rule",
|
||||
"ds-error-boundary": {
|
||||
"description": "Check the data source configuration. Does the data source support Prometheus API?",
|
||||
"title": "Unable to load rules from this data source"
|
||||
@@ -828,15 +838,20 @@
|
||||
"no-more-results": "No more results – showing {{numberOfRules}} rules",
|
||||
"no-rules-found": "No alert or recording rules matched your current set of filters."
|
||||
},
|
||||
"more": "More",
|
||||
"new-alert-rule": "New alert rule",
|
||||
"new-datasource-recording-rule": "New Data source recording rule",
|
||||
"new-grafana-recording-rule": "New Grafana recording rule",
|
||||
"pagination": {
|
||||
"next-page": "next page",
|
||||
"previous-page": "previous page"
|
||||
},
|
||||
"recording-rules": "Recording rules",
|
||||
"return-button": {
|
||||
"title": "Alert rules"
|
||||
},
|
||||
"rulerrule-loading-error": "Failed to load the rule"
|
||||
"rulerrule-loading-error": "Failed to load the rule",
|
||||
"unknown-rule-type": "Unknown rule type"
|
||||
},
|
||||
"rule-state": {
|
||||
"creating": "Creating",
|
||||
@@ -855,7 +870,7 @@
|
||||
"rule-viewer": {
|
||||
"error-loading": "Something went wrong loading the rule",
|
||||
"prometheus-consistency-check": {
|
||||
"alert-message": "Alert rule has been updated. Changes may take up to a minute to appear on the Alert rules list view.",
|
||||
"alert-message": "Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list view.",
|
||||
"alert-title": "Update in progress"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user