Alerting: Add GMA action buttons to the new list view (#98449)

* Add GMA action buttons based on the ruler rule definition

* Improve imports

* Remove rulesSource from Grafana group identifier

* Improve ruler loader error handling

* Clean imports, add details page link

* Remove unnecessary property from the API:

* Change Prometheus page size in the FilterView

* Fix lint errors and tests

* Revert filtered items page size

* Fix cache invalidation for RTKQ ruler requests

* Fix tags ids

* Naming improvements

* Fix lint errors, use util function for pause checking

* Alerting: Add ruleGroupIdentifierV2toV1 function to PR 98449 (#99326)

* Move params validation to the rulerUrlBuilder, tidy up code

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2025-01-27 15:08:33 +01:00 committed by GitHub
parent 49f8359ce5
commit c5ff5d89df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 300 additions and 189 deletions

View File

@ -3,7 +3,12 @@ import { set } from 'lodash';
import { RelativeTimeRange } from '@grafana/data'; import { RelativeTimeRange } from '@grafana/data';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting'; import {
GrafanaRuleGroupIdentifier,
RuleIdentifier,
RuleNamespace,
RulerDataSourceConfig,
} from 'app/types/unified-alerting';
import { import {
AlertQuery, AlertQuery,
Annotations, Annotations,
@ -23,6 +28,7 @@ import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isGrafanaRulerRule, isPrometheusRuleIdentifier } from '../utils/rules'; import { isCloudRuleIdentifier, isGrafanaRulerRule, isPrometheusRuleIdentifier } from '../utils/rules';
import { WithNotificationOptions, alertingApi } from './alertingApi'; import { WithNotificationOptions, alertingApi } from './alertingApi';
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
import { import {
FetchPromRulesFilter, FetchPromRulesFilter,
getRulesFilterSearchParams, getRulesFilterSearchParams,
@ -257,12 +263,20 @@ export const alertRuleApi = alertingApi.injectEndpoints({
notificationOptions, notificationOptions,
}; };
}, },
providesTags: (_result, _error, { namespace, group }) => [ providesTags: (_result, _error, { namespace, group, rulerConfig }) => [
{ { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${group}` },
type: 'RuleGroup', { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` },
id: `${namespace}/${group}`, ],
}, }),
{ type: 'RuleNamespace', id: namespace },
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}` },
], ],
}), }),
@ -284,12 +298,9 @@ export const alertRuleApi = alertingApi.injectEndpoints({
}, },
}; };
}, },
invalidatesTags: (_result, _error, { namespace, group }) => [ invalidatesTags: (_result, _error, { namespace, group, rulerConfig }) => [
{ { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${group}` },
type: 'RuleGroup', { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` },
id: `${namespace}/${group}`,
},
{ type: 'RuleNamespace', id: namespace },
], ],
}), }),
@ -317,12 +328,9 @@ export const alertRuleApi = alertingApi.injectEndpoints({
}, },
}; };
}, },
invalidatesTags: (result, _error, { namespace, payload }) => [ invalidatesTags: (result, _error, { namespace, payload, rulerConfig }) => [
{ type: 'RuleNamespace', id: namespace }, { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` },
{ { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${payload.name}` },
type: 'RuleGroup',
id: `${namespace}/${payload.name}`,
},
...payload.rules ...payload.rules
.filter((rule) => isGrafanaRulerRule(rule)) .filter((rule) => isGrafanaRulerRule(rule))
.map((rule) => ({ type: 'GrafanaRulerRule', id: rule.grafana_alert.uid }) as const), .map((rule) => ({ type: 'GrafanaRulerRule', id: rule.grafana_alert.uid }) as const),

View File

@ -12,6 +12,7 @@ import { discoverAlertmanagerFeatures, discoverFeaturesByUid } from './buildInfo
export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = { export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = {
dataSourceName: 'grafana', dataSourceName: 'grafana',
dataSourceUid: 'grafana',
apiVersion: 'legacy', apiVersion: 'legacy',
}; };
@ -63,6 +64,7 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
const rulerConfig = features.features.rulerApiEnabled const rulerConfig = features.features.rulerApiEnabled
? ({ ? ({
dataSourceName: dataSourceSettings.name, dataSourceName: dataSourceSettings.name,
dataSourceUid: dataSourceSettings.uid,
apiVersion: features.application === PromApplication.Cortex ? 'legacy' : 'config', apiVersion: features.application === PromApplication.Cortex ? 'legacy' : 'config',
} satisfies RulerDataSourceConfig) } satisfies RulerDataSourceConfig)
: undefined; : undefined;

View File

@ -2,17 +2,14 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { mockDataSource } from '../mocks'; import { mockDataSource } from '../mocks';
import { setupDataSources } from '../testSetup/datasources'; import { setupDataSources } from '../testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { DataSourceType } from '../utils/datasource';
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
import { rulerUrlBuilder } from './ruler'; import { rulerUrlBuilder } from './ruler';
const grafanaConfig: RulerDataSourceConfig = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
apiVersion: 'legacy',
};
const mimirConfig: RulerDataSourceConfig = { const mimirConfig: RulerDataSourceConfig = {
dataSourceName: 'Mimir-cloud', dataSourceName: 'Mimir-cloud',
dataSourceUid: 'mimir-1',
apiVersion: 'config', apiVersion: 'config',
}; };
@ -28,6 +25,7 @@ describe('rulerUrlBuilder', () => {
// Arrange // Arrange
const config: RulerDataSourceConfig = { const config: RulerDataSourceConfig = {
dataSourceName: 'Cortex', dataSourceName: 'Cortex',
dataSourceUid: 'cortex-1',
apiVersion: 'legacy', apiVersion: 'legacy',
}; };
@ -121,7 +119,7 @@ describe('rulerUrlBuilder', () => {
// GMA uses folderUIDs as namespaces and they should never contain slashes // GMA uses folderUIDs as namespaces and they should never contain slashes
it('Should only replace the group segment for Grafana-managed rules', () => { it('Should only replace the group segment for Grafana-managed rules', () => {
// Act // Act
const builder = rulerUrlBuilder(grafanaConfig); const builder = rulerUrlBuilder(GRAFANA_RULER_CONFIG);
const group = builder.namespaceGroup('test/ns', 'test/gr'); const group = builder.namespaceGroup('test/ns', 'test/gr');

View File

@ -47,6 +47,13 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) {
}, },
namespaceGroup: (namespaceUID: string, group: string): RulerRequestUrl => { namespaceGroup: (namespaceUID: string, group: string): RulerRequestUrl => {
if (!namespaceUID) {
throw new Error('Namespace UID is required to fetch ruler group');
}
if (!group) {
throw new Error('Group name is required to fetch ruler group');
}
const { namespace: finalNs, searchParams: nsParams } = queryDetailsProvider.namespace(namespaceUID); const { namespace: finalNs, searchParams: nsParams } = queryDetailsProvider.namespace(namespaceUID);
const { group: finalGroup, searchParams: groupParams } = queryDetailsProvider.group(group); const { group: finalGroup, searchParams: groupParams } = queryDetailsProvider.group(group);
@ -107,7 +114,7 @@ function getQueryDetailsProvider(rulerConfig: RulerDataSourceConfig): RulerQuery
} }
function getRulerPath(rulerConfig: RulerDataSourceConfig) { function getRulerPath(rulerConfig: RulerDataSourceConfig) {
const grafanaServerPath = `/api/ruler/${getDatasourceAPIUid(rulerConfig.dataSourceName)}`; const grafanaServerPath = `/api/ruler/${rulerConfig.dataSourceUid}`;
return `${grafanaServerPath}/api/v1/rules`; return `${grafanaServerPath}/api/v1/rules`;
} }

View File

@ -1,15 +1,15 @@
import { Menu } from '@grafana/ui'; import { Menu } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule'; import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
import { isLoading } from '../hooks/useAsync'; import { isLoading } from '../hooks/useAsync';
import { stringifyErrorLike } from '../utils/misc'; import { stringifyErrorLike } from '../utils/misc';
import { isGrafanaRulerRulePaused } from '../utils/rules';
interface Props { interface Props {
rule: RulerRuleDTO; rule: RulerGrafanaRuleDTO;
groupIdentifier: GrafanaRuleGroupIdentifier; groupIdentifier: GrafanaRuleGroupIdentifier;
/** /**
* Method invoked after the request to change the paused state has completed * Method invoked after the request to change the paused state has completed
@ -25,22 +25,16 @@ const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const [pauseRule, updateState] = usePauseRuleInGroup(); const [pauseRule, updateState] = usePauseRuleInGroup();
const isPaused = isGrafanaRulerRule(rule) && isGrafanaRulerRulePaused(rule); const [icon, title] = isGrafanaRulerRulePaused(rule)
const icon = isPaused ? 'play' : 'pause'; ? ['play' as const, 'Resume evaluation']
const title = isPaused ? 'Resume evaluation' : 'Pause evaluation'; : ['pause' as const, 'Pause evaluation'];
/** /**
* Triggers API call to update the current rule to the new `is_paused` state * Triggers API call to update the current rule to the new `is_paused` state
*/ */
const setRulePause = async (newIsPaused: boolean) => { const setRulePause = async (newIsPaused: boolean) => {
if (!isGrafanaRulerRule(rule)) {
return;
}
try { try {
const ruleUID = rule.grafana_alert.uid; await pauseRule.execute(groupIdentifier, rule.grafana_alert.uid, newIsPaused);
await pauseRule.execute(groupIdentifier, ruleUID, newIsPaused);
} catch (error) { } catch (error) {
notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`); notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`);
return; return;
@ -55,7 +49,7 @@ const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
icon={icon} icon={icon}
disabled={isLoading(updateState)} disabled={isLoading(updateState)}
onClick={() => { onClick={() => {
setRulePause(!isPaused); setRulePause(!rule.grafana_alert.is_paused);
}} }}
/> />
); );

View File

@ -10,7 +10,7 @@ import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities'; import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc'; import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isAlertingRule } from '../../utils/rules'; import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url'; import { createRelativeUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton'; import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
@ -86,7 +86,7 @@ const AlertRuleMenu = ({
const menuItems = ( const menuItems = (
<> <>
{canPause && rulerRule && groupIdentifier.groupOrigin === 'grafana' && ( {canPause && isGrafanaRulerRule(rulerRule) && groupIdentifier.groupOrigin === 'grafana' && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} /> <MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
)} )}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />} {canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
@ -131,15 +131,11 @@ const AlertRuleMenu = ({
); );
}; };
function copyToClipboard(text: string) { interface ExportMenuItemProps {
navigator.clipboard?.writeText(text).then(() => { identifier: RuleIdentifier;
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
} }
type PropsWithIdentifier = { identifier: RuleIdentifier }; const ExportMenuItem = ({ identifier }: ExportMenuItemProps) => {
const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
const returnTo = location.pathname + location.search; const returnTo = location.pathname + location.search;
const url = createRelativeUrl( const url = createRelativeUrl(
`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`,
@ -151,4 +147,10 @@ const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />; return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />;
}; };
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
}
export default AlertRuleMenu; export default AlertRuleMenu;

View File

@ -3,18 +3,21 @@ import { useCallback, useMemo, useState } from 'react';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { ConfirmModal } from '@grafana/ui'; import { ConfirmModal } from '@grafana/ui';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting'; import { EditableRuleIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions'; import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id'; import { ruleGroupIdentifierV2toV1 } from '../../utils/groupIdentifier';
import { isCloudRuleIdentifier } from '../../utils/rules'; import { isCloudRuleIdentifier } from '../../utils/rules';
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void, () => void]; type DeleteModalHook = [
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifierV2 } | undefined; JSX.Element,
(ruleIdentifier: EditableRuleIdentifier, groupIdentifier: RuleGroupIdentifierV2) => void,
() => void,
];
type DeleteRuleInfo = { ruleIdentifier: EditableRuleIdentifier; groupIdentifier: RuleGroupIdentifierV2 } | undefined;
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
@ -27,8 +30,8 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
setRuleToDelete(undefined); setRuleToDelete(undefined);
}, []); }, []);
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => { const showModal = useCallback((ruleIdentifier: EditableRuleIdentifier, groupIdentifier: RuleGroupIdentifierV2) => {
setRuleToDelete({ rule, groupIdentifier }); setRuleToDelete({ ruleIdentifier, groupIdentifier });
}, []); }, []);
const deleteRule = useCallback(async () => { const deleteRule = useCallback(async () => {
@ -36,26 +39,22 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
return; return;
} }
const { rule, groupIdentifier } = ruleToDelete; const { ruleIdentifier, groupIdentifier } = ruleToDelete;
const groupIdentifierV1 = ruleGroupIdentifierV2toV1(groupIdentifier);
const rulesSourceName = groupIdentifierV1.dataSourceName;
const groupIdentifierV1: RuleGroupIdentifier = {
dataSourceName: groupIdentifier.rulesSource.name,
namespaceName:
'uid' in groupIdentifier.namespace ? groupIdentifier.namespace.uid : groupIdentifier.namespace.name,
groupName: groupIdentifier.groupName,
};
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifierV1, rule);
await deleteRuleFromGroup.execute(groupIdentifierV1, ruleIdentifier); await deleteRuleFromGroup.execute(groupIdentifierV1, ruleIdentifier);
// refetch rules for this rules source // refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags // @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name })); dispatch(fetchPromAndRulerRulesAction({ rulesSourceName }));
if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) { if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) {
await waitForRemoval(ruleIdentifier); await waitForRemoval(ruleIdentifier);
} else { } else {
// Without this the delete popup will close and the user will still see the deleted rule // Without this the delete popup will close and the user will still see the deleted rule
await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name })); await dispatch(fetchRulerRulesAction({ rulesSourceName }));
} }
dismissModal(); dismissModal();

View File

@ -107,7 +107,8 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
groupIdentifier={groupId} groupIdentifier={groupId}
handleDelete={() => { handleDelete={() => {
if (rule.rulerRule) { if (rule.rulerRule) {
showDeleteModal(rule.rulerRule, groupId); const editableRuleIdentifier = ruleId.fromRulerRuleAndGroupIdentifierV2(groupId, rule.rulerRule);
showDeleteModal(editableRuleIdentifier, groupId);
} }
}} }}
handleSilence={() => setShowSilenceDrawer(true)} handleSilence={() => setShowSilenceDrawer(true)}

View File

@ -1,8 +1,9 @@
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { GrafanaRuleGroupIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { pauseRuleAction } from '../../reducers/ruler/ruleGroups'; import { pauseRuleAction } from '../../reducers/ruler/ruleGroups';
import { ruleGroupIdentifierV2toV1 } from '../../utils/groupIdentifier';
import { useAsync } from '../useAsync'; import { useAsync } from '../useAsync';
import { useProduceNewRuleGroup } from './useProduceNewRuleGroup'; import { useProduceNewRuleGroup } from './useProduceNewRuleGroup';
@ -19,11 +20,8 @@ export function usePauseRuleInGroup() {
const ruleResumedMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed'); const ruleResumedMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
return useAsync(async (ruleGroup: GrafanaRuleGroupIdentifier, uid: string, pause: boolean) => { return useAsync(async (ruleGroup: GrafanaRuleGroupIdentifier, uid: string, pause: boolean) => {
const groupIdentifierV1: RuleGroupIdentifier = { const groupIdentifierV1 = ruleGroupIdentifierV2toV1(ruleGroup);
dataSourceName: ruleGroup.rulesSource.name,
namespaceName: ruleGroup.namespace.uid,
groupName: ruleGroup.groupName,
};
const action = pauseRuleAction({ uid, pause }); const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, action); const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, action);

View File

@ -20,6 +20,7 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext'; import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { getRulesSourceName } from '../utils/datasource'; import { getRulesSourceName } from '../utils/datasource';
import { getGroupOriginName } from '../utils/groupIdentifier';
import { isAdmin } from '../utils/misc'; import { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
@ -242,7 +243,7 @@ export function useAllRulerRuleAbilities(
rule: RulerRuleDTO | undefined, rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifierV2 groupIdentifier: RuleGroupIdentifierV2
): Abilities<AlertRuleAction> { ): Abilities<AlertRuleAction> {
const rulesSourceName = groupIdentifier.rulesSource.name; const rulesSourceName = getGroupOriginName(groupIdentifier);
const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule); const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
@ -443,9 +444,8 @@ function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] {
const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule); const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule);
const isGrafanaRecording = rule && isGrafanaRecordingRule(rule); const isGrafanaRecording = rule && isGrafanaRecordingRule(rule);
const { currentData: amConfigStatus, isLoading } = useGetGrafanaAlertingConfigurationStatusQuery(undefined, { const silenceSupported = useGrafanaRulesSilenceSupport();
skip: !isGrafanaManagedRule || !rule, const canSilenceInFolder = useCanSilenceInFolder(folderUID);
});
if (!rule) { if (!rule) {
return [false, false]; return [false, false];
@ -453,24 +453,38 @@ function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] {
// we don't support silencing when the rule is not a Grafana managed alerting rule // we don't support silencing when the rule is not a Grafana managed alerting rule
// we simply don't know what Alertmanager the ruler is sending alerts to // we simply don't know what Alertmanager the ruler is sending alerts to
if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) { if (!isGrafanaManagedRule || isGrafanaRecording || folderIsLoading || !folder) {
return [false, false]; return [false, false];
} }
return [silenceSupported, canSilenceInFolder];
}
function useCanSilenceInFolder(folderUID?: string) {
const folderPermissions = useFolderPermissions(folderUID);
const hasFolderSilencePermission = folderPermissions[AccessControlAction.AlertingSilenceCreate] ?? false;
const hasGlobalSilencePermission = ctx.hasPermission(AccessControlAction.AlertingInstanceCreate);
// User is permitted to silence if they either have the "global" permissions of "AlertingInstanceCreate",
// or the folder specific access control of "AlertingSilenceCreate"
const allowedToSilence = hasGlobalSilencePermission || hasFolderSilencePermission;
return allowedToSilence;
}
function useGrafanaRulesSilenceSupport() {
const { currentData: amConfigStatus, isLoading } = useGetGrafanaAlertingConfigurationStatusQuery(undefined);
const interactsOnlyWithExternalAMs = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.External; const interactsOnlyWithExternalAMs = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.External;
const interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All; const interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All;
const silenceSupported = !interactsOnlyWithExternalAMs || interactsWithAll; const silenceSupported = !interactsOnlyWithExternalAMs || interactsWithAll;
const { accessControl = {} } = folder; return isLoading ? false : silenceSupported;
}
// User is permitted to silence if they either have the "global" permissions of "AlertingInstanceCreate", function useFolderPermissions(folderUID?: string): Record<string, boolean> {
// or the folder specific access control of "AlertingSilenceCreate" const { folder } = useFolder(folderUID);
const allowedToSilence = Boolean( return folder?.accessControl ?? {};
ctx.hasPermission(AccessControlAction.AlertingInstanceCreate) ||
accessControl[AccessControlAction.AlertingSilenceCreate]
);
return [silenceSupported, allowedToSilence];
} }
// just a convenient function // just a convenient function

View File

@ -9,7 +9,8 @@ import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/r
import { createRelativeUrl } from '../utils/url'; import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem'; import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi; const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
@ -63,7 +64,7 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
// 2.2 render provisioning badge and contact point metadata, etc. // 2.2 render provisioning badge and contact point metadata, etc.
const actions = useMemo(() => { const actions = useMemo(() => {
if (isLoading) { if (isLoading) {
return <ActionsLoader />; return <RuleActionsSkeleton />;
} }
if (rulerRule) { if (rulerRule) {

View File

@ -1,6 +1,5 @@
import { take, tap, withAbort } from 'ix/asynciterable/operators'; import { take, tap, withAbort } from 'ix/asynciterable/operators';
import { useEffect, useRef, useState, useTransition } from 'react'; import { useEffect, useRef, useState, useTransition } from 'react';
import Skeleton from 'react-loading-skeleton';
import { Card, EmptyState, Stack, Text } from '@grafana/ui'; import { Card, EmptyState, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
@ -13,9 +12,7 @@ import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { GrafanaRuleLoader } from './GrafanaRuleLoader'; import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import LoadMoreHelper from './LoadMoreHelper'; import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem'; import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { ListItem } from './components/ListItem'; import { AlertRuleListItemLoader } from './components/AlertRuleListItemLoader';
import { ActionsLoader } from './components/RuleActionsButtons.V2';
import { RuleListIcon } from './components/RuleListIcon';
import { import {
GrafanaRuleWithOrigin, GrafanaRuleWithOrigin,
PromRuleWithOrigin, PromRuleWithOrigin,
@ -123,7 +120,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
<GrafanaRuleLoader <GrafanaRuleLoader
key={key} key={key}
rule={rule} rule={rule}
groupName={groupIdentifier.groupName} groupIdentifier={groupIdentifier}
namespaceName={ruleWithOrigin.namespaceName} namespaceName={ruleWithOrigin.namespaceName}
/> />
); );
@ -149,21 +146,11 @@ function FilterViewResults({ filterState }: FilterViewProps) {
</Text> </Text>
</Card> </Card>
)} )}
{!doneSearching && <LoadMoreHelper handleLoad={loadResultPage} />} {!doneSearching && !loading && <LoadMoreHelper handleLoad={loadResultPage} />}
</Stack> </Stack>
); );
} }
const AlertRuleListItemLoader = () => (
<ListItem
title={<Skeleton width={64} />}
icon={<RuleListIcon isPaused={false} />}
description={<Skeleton width={256} />}
actions={<ActionsLoader />}
data-testid="alert-rule-list-item-loader"
/>
);
// simple helper function to detect the end of the source async iterable // simple helper function to detect the end of the source async iterable
function onFinished<T>(fn: () => void) { function onFinished<T>(fn: () => void) {
return tap<T>(undefined, undefined, fn); return tap<T>(undefined, undefined, fn);

View File

@ -1,56 +1,70 @@
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto'; import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { GrafanaRulesSource } from '../utils/datasource'; import { GrafanaRulesSource } from '../utils/datasource';
import { createRelativeUrl } from '../utils/url'; import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem'; import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemLoader, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
interface GrafanaRuleLoaderProps { interface GrafanaRuleLoaderProps {
rule: GrafanaPromRuleDTO; rule: GrafanaPromRuleDTO;
groupName: string; groupIdentifier: GrafanaRuleGroupIdentifier;
namespaceName: string; namespaceName: string;
} }
export function GrafanaRuleLoader({ rule, groupName, namespaceName }: GrafanaRuleLoaderProps) { export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) {
const { folderUid } = rule; const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery(groupIdentifier);
const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === rule.uid);
if (!rulerRule) {
if (isError) {
return <RulerRuleLoadingError rule={rule} />;
}
return <AlertRuleListItemLoader />;
}
const {
grafana_alert: { title, provenance, is_paused },
annotations = {},
labels = {},
} = rulerRule;
const commonProps = { const commonProps = {
name: rule.name, name: title,
rulesSource: GrafanaRulesSource, rulesSource: GrafanaRulesSource,
group: groupName, group: groupIdentifier.groupName,
namespace: namespaceName, namespace: namespaceName,
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`), href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
health: rule.health, health: rule.health,
error: rule.lastError, error: rule.lastError,
labels: rule.labels, labels: labels,
isProvisioned: Boolean(provenance),
isPaused: is_paused,
application: 'grafana' as const,
actions: <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />,
}; };
if (rule.type === PromRuleType.Alerting) { if (rule.type === PromRuleType.Alerting) {
return ( return (
<AlertRuleListItem <AlertRuleListItem
{...commonProps} {...commonProps}
application="grafana" summary={annotations.summary}
summary={rule.annotations?.summary}
state={rule.state} state={rule.state}
isProvisioned={undefined}
instancesCount={rule.alerts?.length} instancesCount={rule.alerts?.length}
/> />
); );
} }
if (rule.type === PromRuleType.Recording) { if (rule.type === PromRuleType.Recording) {
return <RecordingRuleListItem {...commonProps} application="grafana" isProvisioned={undefined} />; return <RecordingRuleListItem {...commonProps} />;
} }
return ( return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
<UnknownRuleListItem
rule={rule}
groupIdentifier={{
rulesSource: GrafanaRulesSource,
groupName,
namespace: { uid: folderUid },
groupOrigin: 'grafana',
}}
/>
);
} }

View File

@ -2,7 +2,7 @@ import { groupBy } from 'lodash';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { Icon, Stack, Text } from '@grafana/ui'; import { Icon, Stack, Text } from '@grafana/ui';
import { GrafanaRulesSourceSymbol } from 'app/types/unified-alerting'; import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaRuleLoader } from './GrafanaRuleLoader'; import { GrafanaRuleLoader } from './GrafanaRuleLoader';
@ -83,10 +83,25 @@ interface GrafanaRuleGroupListItemProps {
namespaceName: string; namespaceName: string;
} }
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) { export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
const groupIdentifier: GrafanaRuleGroupIdentifier = {
groupName: group.name,
namespace: {
uid: group.folderUid,
},
groupOrigin: 'grafana',
};
return ( return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}> <ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{group.rules.map((rule) => { {group.rules.map((rule) => {
return <GrafanaRuleLoader key={rule.uid} rule={rule} groupName={group.name} namespaceName={namespaceName} />; return (
<GrafanaRuleLoader
key={rule.uid}
rule={rule}
namespaceName={namespaceName}
groupIdentifier={groupIdentifier}
/>
);
})} })}
</ListGroup> </ListGroup>
); );

View File

@ -16,7 +16,8 @@ import { hashRule } from '../utils/rule-id';
import { getRulePluginOrigin, isAlertingRule, isGrafanaRulerRule } from '../utils/rules'; import { getRulePluginOrigin, isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
import { AlertRuleListItem } from './components/AlertRuleListItem'; import { AlertRuleListItem } from './components/AlertRuleListItem';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
interface Props { interface Props {
namespaces: CombinedRuleNamespace[]; namespaces: CombinedRuleNamespace[];
@ -124,7 +125,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
rule.rulerRule ? ( rule.rulerRule ? (
<RuleActionsButtons compact rule={rule.rulerRule} promRule={promRule} groupIdentifier={groupId} /> <RuleActionsButtons compact rule={rule.rulerRule} promRule={promRule} groupIdentifier={groupId} />
) : ( ) : (
<ActionsLoader /> <RuleActionsSkeleton />
) )
} }
origin={originMeta} origin={originMeta}

View File

@ -5,13 +5,7 @@ import { ReactNode, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from 'app/types/unified-alerting';
GrafanaRulesSourceSymbol,
Rule,
RuleGroupIdentifierV2,
RuleHealth,
RulesSourceIdentifier,
} from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics'; import { logError } from '../../Analytics';
@ -19,6 +13,7 @@ import { MetaText } from '../../components/MetaText';
import { ProvisioningBadge } from '../../components/Provisioning'; import { ProvisioningBadge } from '../../components/Provisioning';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { getGroupOriginName } from '../../utils/groupIdentifier';
import { labelsSize } from '../../utils/labels'; import { labelsSize } from '../../utils/labels';
import { createContactPointSearchLink } from '../../utils/misc'; import { createContactPointSearchLink } from '../../utils/misc';
import { RulePluginOrigin } from '../../utils/rules'; import { RulePluginOrigin } from '../../utils/rules';
@ -268,12 +263,12 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
useEffect(() => { useEffect(() => {
const { rulesSource, namespace, groupName } = groupIdentifier; const { namespace, groupName } = groupIdentifier;
const ruleContext = { const ruleContext = {
name: rule.name, name: rule.name,
groupName, groupName,
namespace: JSON.stringify(namespace), namespace: JSON.stringify(namespace),
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid, rulesSource: getGroupOriginName(groupIdentifier),
}; };
logError(new Error('unknown rule type'), ruleContext); logError(new Error('unknown rule type'), ruleContext);
}, [rule, groupIdentifier]); }, [rule, groupIdentifier]);

View File

@ -0,0 +1,30 @@
import Skeleton from 'react-loading-skeleton';
import { t } from 'app/core/internationalization';
import { PromRuleDTO } from 'app/types/unified-alerting-dto';
import { ListItem } from './ListItem';
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
import { RuleListIcon } from './RuleListIcon';
export function AlertRuleListItemLoader() {
return (
<ListItem
title={<Skeleton width={64} />}
icon={<RuleListIcon isPaused={false} />}
description={<Skeleton width={256} />}
actions={<RuleActionsSkeleton />}
data-testid="alert-rule-list-item-loader"
/>
);
}
export function RulerRuleLoadingError({ rule }: { rule: PromRuleDTO }) {
return (
<ListItem
title={rule.name}
description={t('alerting.rule-list.rulerrule-loading-error', 'Failed to load the rule')}
data-testid="ruler-rule-loading-error"
/>
);
}

View File

@ -1,21 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { LinkButton, Stack } from '@grafana/ui'; import { LinkButton, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal'; import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
import { RedirectToCloneRule } from 'app/features/alerting/unified/components/rules/CloneRule'; import { RedirectToCloneRule } from 'app/features/alerting/unified/components/rules/CloneRule';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer';
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
import { useDispatch } from 'app/types';
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting'; import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities'; import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url'; import { createRelativeUrl } from '../../utils/url';
@ -34,8 +28,6 @@ interface Props {
// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed. // For now this is just a copy of RuleActionsButtons.tsx but with the View button removed.
// This is only done to keep the new list behind a feature flag and limit changes in the existing components // This is only done to keep the new list behind a feature flag and limit changes in the existing components
export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }: Props) { export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }: Props) {
const dispatch = useDispatch();
const redirectToListView = compact ? false : true; const redirectToListView = compact ? false : true;
const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView); const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView);
@ -45,8 +37,6 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined { identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined); >(undefined);
const { hasActiveFilters } = useRulesFilter();
const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance); const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance);
const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update); const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update);
@ -77,18 +67,9 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
promRule={promRule} promRule={promRule}
groupIdentifier={groupIdentifier} groupIdentifier={groupIdentifier}
identifier={identifier} identifier={identifier}
handleDelete={() => showDeleteModal(rule, groupIdentifier)} handleDelete={() => showDeleteModal(identifier, groupIdentifier)}
handleSilence={() => setShowSilenceDrawer(true)} handleSilence={() => setShowSilenceDrawer(true)}
handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })}
onPauseChange={() => {
// Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect
// of breaking some unrelated tests in Policy.test.tsx due to mocking approach
const limitAlerts = hasActiveFilters ? undefined : INSTANCES_DISPLAY_LIMIT + 1;
// Trigger a re-fetch of the rules table
// TODO: Migrate rules table functionality to RTK Query, so we instead rely
// on tag invalidation (or optimistic cache updates) for this
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts }));
}}
/> />
{deleteModal} {deleteModal}
{isGrafanaAlertingRule(rule) && showSilenceDrawer && ( {isGrafanaAlertingRule(rule) && showSilenceDrawer && (
@ -104,5 +85,3 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
</Stack> </Stack>
); );
} }
export const ActionsLoader = () => <Skeleton width={50} height={16} />;

View File

@ -0,0 +1,5 @@
import Skeleton from 'react-loading-skeleton';
export function RuleActionsSkeleton() {
return <Skeleton width={50} height={16} />;
}

View File

@ -1,3 +1,4 @@
import { memo } from 'react';
import type { RequireAtLeastOne } from 'type-fest'; import type { RequireAtLeastOne } from 'type-fest';
import { Icon, type IconName, Text, Tooltip } from '@grafana/ui'; import { Icon, type IconName, Text, Tooltip } from '@grafana/ui';
@ -14,33 +15,34 @@ interface RuleListIconProps {
isPaused?: boolean; isPaused?: boolean;
} }
const icons: Record<PromAlertingRuleState, IconName> = {
[PromAlertingRuleState.Inactive]: 'check-circle',
[PromAlertingRuleState.Pending]: 'circle',
[PromAlertingRuleState.Firing]: 'exclamation-circle',
};
const color: Record<PromAlertingRuleState, 'success' | 'error' | 'warning'> = {
[PromAlertingRuleState.Inactive]: 'success',
[PromAlertingRuleState.Pending]: 'warning',
[PromAlertingRuleState.Firing]: 'error',
};
const stateNames: Record<PromAlertingRuleState, string> = {
[PromAlertingRuleState.Inactive]: 'Normal',
[PromAlertingRuleState.Pending]: 'Pending',
[PromAlertingRuleState.Firing]: 'Firing',
};
/** /**
* Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view * 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
*/ */
export function RuleListIcon({ export const RuleListIcon = memo(function RuleListIcon({
state, state,
health, health,
recording = false, recording = false,
isPaused = false, isPaused = false,
}: RequireAtLeastOne<RuleListIconProps>) { }: RequireAtLeastOne<RuleListIconProps>) {
const icons: Record<PromAlertingRuleState, IconName> = {
[PromAlertingRuleState.Inactive]: 'check-circle',
[PromAlertingRuleState.Pending]: 'circle',
[PromAlertingRuleState.Firing]: 'exclamation-circle',
};
const color: Record<PromAlertingRuleState, 'success' | 'error' | 'warning'> = {
[PromAlertingRuleState.Inactive]: 'success',
[PromAlertingRuleState.Pending]: 'warning',
[PromAlertingRuleState.Firing]: 'error',
};
const stateNames: Record<PromAlertingRuleState, string> = {
[PromAlertingRuleState.Inactive]: 'Normal',
[PromAlertingRuleState.Pending]: 'Pending',
[PromAlertingRuleState.Firing]: 'Firing',
};
let iconName: IconName = state ? icons[state] : 'circle'; let iconName: IconName = state ? icons[state] : 'circle';
let iconColor: TextProps['color'] = state ? color[state] : 'secondary'; let iconColor: TextProps['color'] = state ? color[state] : 'secondary';
let stateName: string = state ? stateNames[state] : 'unknown'; let stateName: string = state ? stateNames[state] : 'unknown';
@ -78,4 +80,4 @@ export function RuleListIcon({
</div> </div>
</Tooltip> </Tooltip>
); );
} });

View File

@ -19,7 +19,7 @@ import {
import { RulesFilter } from '../../search/rulesSearchParser'; import { RulesFilter } from '../../search/rulesSearchParser';
import { labelsMatchMatchers } from '../../utils/alertmanager'; import { labelsMatchMatchers } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { GrafanaRulesSource, getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource'; import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
import { parseMatcher } from '../../utils/matchers'; import { parseMatcher } from '../../utils/matchers';
import { isAlertingRule } from '../../utils/rules'; import { isAlertingRule } from '../../utils/rules';
@ -109,7 +109,6 @@ function mapGrafanaRuleToRuleWithOrigin(
return { return {
rule, rule,
groupIdentifier: { groupIdentifier: {
rulesSource: GrafanaRulesSource,
namespace: { uid: group.folderUid }, namespace: { uid: group.folderUid },
groupName: group.name, groupName: group.name,
groupOrigin: 'grafana', groupOrigin: 'grafana',

View File

@ -0,0 +1,44 @@
import { RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { ruleGroupIdentifierV2toV1 } from './groupIdentifier';
describe('ruleGroupIdentifierV2toV1', () => {
it('should convert grafana v2 rule group identifier to v1 format', () => {
const identifier: RuleGroupIdentifierV2 = {
groupName: 'group-1',
namespace: {
uid: 'uid123',
},
groupOrigin: 'grafana',
};
const result = ruleGroupIdentifierV2toV1(identifier);
expect(result).toStrictEqual<RuleGroupIdentifier>({
dataSourceName: 'grafana',
groupName: 'group-1',
namespaceName: 'uid123',
});
});
it('should convert data source v2 rule group identifier to v1 format', () => {
const identifier: RuleGroupIdentifierV2 = {
groupName: 'group-1',
namespace: {
name: 'namespace-1',
},
rulesSource: {
uid: 'ds-uid123',
name: 'ds-name',
ruleSourceType: 'datasource',
},
groupOrigin: 'datasource',
};
const result = ruleGroupIdentifierV2toV1(identifier);
expect(result).toStrictEqual<RuleGroupIdentifier>({
dataSourceName: 'ds-name',
groupName: 'group-1',
namespaceName: 'namespace-1',
});
});
});

View File

@ -1,4 +1,4 @@
import { CombinedRule, GrafanaRulesSourceSymbol, RuleGroupIdentifierV2 } from 'app/types/unified-alerting'; import { CombinedRule, RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource';
import { isGrafanaRulerRule } from './rules'; import { isGrafanaRulerRule } from './rules';
@ -6,7 +6,6 @@ import { isGrafanaRulerRule } from './rules';
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 { function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) { if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
return { return {
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' },
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid }, namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
groupName: rule.group.name, groupName: rule.group.name,
groupOrigin: 'grafana', groupOrigin: 'grafana',
@ -23,6 +22,21 @@ function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
}; };
} }
export function getGroupOriginName(groupIdentifier: RuleGroupIdentifierV2) {
return groupIdentifier.groupOrigin === 'grafana' ? GRAFANA_RULES_SOURCE_NAME : groupIdentifier.rulesSource.name;
}
/** Helper function to convert RuleGroupIdentifier to RuleGroupIdentifierV2 */
export function ruleGroupIdentifierV2toV1(groupIdentifier: RuleGroupIdentifierV2): RuleGroupIdentifier {
const rulesSourceName = getGroupOriginName(groupIdentifier);
return {
dataSourceName: rulesSourceName,
namespaceName: 'uid' in groupIdentifier.namespace ? groupIdentifier.namespace.uid : groupIdentifier.namespace.name,
groupName: groupIdentifier.groupName,
};
}
export const groupIdentifier = { export const groupIdentifier = {
fromCombinedRule, fromCombinedRule,
}; };

View File

@ -209,7 +209,6 @@ export interface DataSourceNamespaceIdentifier {
} }
export interface GrafanaRuleGroupIdentifier { export interface GrafanaRuleGroupIdentifier {
rulesSource: GrafanaRulesSourceIdentifier;
groupName: string; groupName: string;
namespace: GrafanaNamespaceIdentifier; namespace: GrafanaNamespaceIdentifier;
groupOrigin: 'grafana'; groupOrigin: 'grafana';
@ -310,6 +309,7 @@ export interface StateHistoryItem {
export interface RulerDataSourceConfig { export interface RulerDataSourceConfig {
dataSourceName: string; dataSourceName: string;
dataSourceUid: string;
apiVersion: 'legacy' | 'config'; apiVersion: 'legacy' | 'config';
} }

View File

@ -506,7 +506,8 @@
}, },
"return-button": { "return-button": {
"title": "Alert rules" "title": "Alert rules"
} },
"rulerrule-loading-error": "Failed to load the rule"
}, },
"rule-state": { "rule-state": {
"creating": "Creating", "creating": "Creating",

View File

@ -506,7 +506,8 @@
}, },
"return-button": { "return-button": {
"title": "Åľęřŧ řūľęş" "title": "Åľęřŧ řūľęş"
} },
"rulerrule-loading-error": "Fäįľęđ ŧő ľőäđ ŧĥę řūľę"
}, },
"rule-state": { "rule-state": {
"creating": "Cřęäŧįʼnģ", "creating": "Cřęäŧįʼnģ",