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 { t } from 'app/core/internationalization';
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 {
AlertQuery,
Annotations,
@ -23,6 +28,7 @@ import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isGrafanaRulerRule, isPrometheusRuleIdentifier } from '../utils/rules';
import { WithNotificationOptions, alertingApi } from './alertingApi';
import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi';
import {
FetchPromRulesFilter,
getRulesFilterSearchParams,
@ -257,12 +263,20 @@ export const alertRuleApi = alertingApi.injectEndpoints({
notificationOptions,
};
},
providesTags: (_result, _error, { namespace, group }) => [
{
type: 'RuleGroup',
id: `${namespace}/${group}`,
},
{ type: 'RuleNamespace', id: namespace },
providesTags: (_result, _error, { namespace, group, rulerConfig }) => [
{ type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${group}` },
{ type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${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 }) => [
{
type: 'RuleGroup',
id: `${namespace}/${group}`,
},
{ type: 'RuleNamespace', id: namespace },
invalidatesTags: (_result, _error, { namespace, group, rulerConfig }) => [
{ type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${group}` },
{ type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` },
],
}),
@ -317,12 +328,9 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
};
},
invalidatesTags: (result, _error, { namespace, payload }) => [
{ type: 'RuleNamespace', id: namespace },
{
type: 'RuleGroup',
id: `${namespace}/${payload.name}`,
},
invalidatesTags: (result, _error, { namespace, payload, rulerConfig }) => [
{ type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` },
{ type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${payload.name}` },
...payload.rules
.filter((rule) => isGrafanaRulerRule(rule))
.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 = {
dataSourceName: 'grafana',
dataSourceUid: 'grafana',
apiVersion: 'legacy',
};
@ -63,6 +64,7 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
const rulerConfig = features.features.rulerApiEnabled
? ({
dataSourceName: dataSourceSettings.name,
dataSourceUid: dataSourceSettings.uid,
apiVersion: features.application === PromApplication.Cortex ? 'legacy' : 'config',
} satisfies RulerDataSourceConfig)
: undefined;

View File

@ -2,17 +2,14 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { mockDataSource } from '../mocks';
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';
const grafanaConfig: RulerDataSourceConfig = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
apiVersion: 'legacy',
};
const mimirConfig: RulerDataSourceConfig = {
dataSourceName: 'Mimir-cloud',
dataSourceUid: 'mimir-1',
apiVersion: 'config',
};
@ -28,6 +25,7 @@ describe('rulerUrlBuilder', () => {
// Arrange
const config: RulerDataSourceConfig = {
dataSourceName: 'Cortex',
dataSourceUid: 'cortex-1',
apiVersion: 'legacy',
};
@ -121,7 +119,7 @@ describe('rulerUrlBuilder', () => {
// GMA uses folderUIDs as namespaces and they should never contain slashes
it('Should only replace the group segment for Grafana-managed rules', () => {
// Act
const builder = rulerUrlBuilder(grafanaConfig);
const builder = rulerUrlBuilder(GRAFANA_RULER_CONFIG);
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 => {
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 { group: finalGroup, searchParams: groupParams } = queryDetailsProvider.group(group);
@ -107,7 +114,7 @@ function getQueryDetailsProvider(rulerConfig: RulerDataSourceConfig): RulerQuery
}
function getRulerPath(rulerConfig: RulerDataSourceConfig) {
const grafanaServerPath = `/api/ruler/${getDatasourceAPIUid(rulerConfig.dataSourceName)}`;
const grafanaServerPath = `/api/ruler/${rulerConfig.dataSourceUid}`;
return `${grafanaServerPath}/api/v1/rules`;
}

View File

@ -1,15 +1,15 @@
import { Menu } from '@grafana/ui';
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 { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
import { isLoading } from '../hooks/useAsync';
import { stringifyErrorLike } from '../utils/misc';
import { isGrafanaRulerRulePaused } from '../utils/rules';
interface Props {
rule: RulerRuleDTO;
rule: RulerGrafanaRuleDTO;
groupIdentifier: GrafanaRuleGroupIdentifier;
/**
* 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 [pauseRule, updateState] = usePauseRuleInGroup();
const isPaused = isGrafanaRulerRule(rule) && isGrafanaRulerRulePaused(rule);
const icon = isPaused ? 'play' : 'pause';
const title = isPaused ? 'Resume evaluation' : 'Pause evaluation';
const [icon, title] = isGrafanaRulerRulePaused(rule)
? ['play' as const, 'Resume evaluation']
: ['pause' as const, 'Pause evaluation'];
/**
* Triggers API call to update the current rule to the new `is_paused` state
*/
const setRulePause = async (newIsPaused: boolean) => {
if (!isGrafanaRulerRule(rule)) {
return;
}
try {
const ruleUID = rule.grafana_alert.uid;
await pauseRule.execute(groupIdentifier, ruleUID, newIsPaused);
await pauseRule.execute(groupIdentifier, rule.grafana_alert.uid, newIsPaused);
} catch (error) {
notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`);
return;
@ -55,7 +49,7 @@ const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
icon={icon}
disabled={isLoading(updateState)}
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 { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isAlertingRule } from '../../utils/rules';
import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
@ -86,7 +86,7 @@ const AlertRuleMenu = ({
const menuItems = (
<>
{canPause && rulerRule && groupIdentifier.groupOrigin === 'grafana' && (
{canPause && isGrafanaRulerRule(rulerRule) && groupIdentifier.groupOrigin === 'grafana' && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
)}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
@ -131,15 +131,11 @@ const AlertRuleMenu = ({
);
};
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
interface ExportMenuItemProps {
identifier: RuleIdentifier;
}
type PropsWithIdentifier = { identifier: RuleIdentifier };
const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
const ExportMenuItem = ({ identifier }: ExportMenuItemProps) => {
const returnTo = location.pathname + location.search;
const url = createRelativeUrl(
`/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} />;
};
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
}
export default AlertRuleMenu;

View File

@ -3,18 +3,21 @@ import { useCallback, useMemo, useState } from 'react';
import { locationService } from '@grafana/runtime';
import { ConfirmModal } from '@grafana/ui';
import { dispatch } from 'app/store/store';
import { RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { EditableRuleIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { ruleGroupIdentifierV2toV1 } from '../../utils/groupIdentifier';
import { isCloudRuleIdentifier } from '../../utils/rules';
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void, () => void];
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifierV2 } | undefined;
type DeleteModalHook = [
JSX.Element,
(ruleIdentifier: EditableRuleIdentifier, groupIdentifier: RuleGroupIdentifierV2) => void,
() => void,
];
type DeleteRuleInfo = { ruleIdentifier: EditableRuleIdentifier; groupIdentifier: RuleGroupIdentifierV2 } | undefined;
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
@ -27,8 +30,8 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
setRuleToDelete(undefined);
}, []);
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => {
setRuleToDelete({ rule, groupIdentifier });
const showModal = useCallback((ruleIdentifier: EditableRuleIdentifier, groupIdentifier: RuleGroupIdentifierV2) => {
setRuleToDelete({ ruleIdentifier, groupIdentifier });
}, []);
const deleteRule = useCallback(async () => {
@ -36,26 +39,22 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
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);
// refetch rules for this rules source
// @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)) {
await waitForRemoval(ruleIdentifier);
} else {
// 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();

View File

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

View File

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

View File

@ -20,6 +20,7 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { getRulesSourceName } from '../utils/datasource';
import { getGroupOriginName } from '../utils/groupIdentifier';
import { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
@ -242,7 +243,7 @@ export function useAllRulerRuleAbilities(
rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifierV2
): Abilities<AlertRuleAction> {
const rulesSourceName = groupIdentifier.rulesSource.name;
const rulesSourceName = getGroupOriginName(groupIdentifier);
const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
@ -443,9 +444,8 @@ function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] {
const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule);
const isGrafanaRecording = rule && isGrafanaRecordingRule(rule);
const { currentData: amConfigStatus, isLoading } = useGetGrafanaAlertingConfigurationStatusQuery(undefined, {
skip: !isGrafanaManagedRule || !rule,
});
const silenceSupported = useGrafanaRulesSilenceSupport();
const canSilenceInFolder = useCanSilenceInFolder(folderUID);
if (!rule) {
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 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 [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 interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All;
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",
// or the folder specific access control of "AlertingSilenceCreate"
const allowedToSilence = Boolean(
ctx.hasPermission(AccessControlAction.AlertingInstanceCreate) ||
accessControl[AccessControlAction.AlertingSilenceCreate]
);
return [silenceSupported, allowedToSilence];
function useFolderPermissions(folderUID?: string): Record<string, boolean> {
const { folder } = useFolder(folderUID);
return folder?.accessControl ?? {};
}
// just a convenient function

View File

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

View File

@ -1,6 +1,5 @@
import { take, tap, withAbort } from 'ix/asynciterable/operators';
import { useEffect, useRef, useState, useTransition } from 'react';
import Skeleton from 'react-loading-skeleton';
import { Card, EmptyState, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
@ -13,9 +12,7 @@ import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { ListItem } from './components/ListItem';
import { ActionsLoader } from './components/RuleActionsButtons.V2';
import { RuleListIcon } from './components/RuleListIcon';
import { AlertRuleListItemLoader } from './components/AlertRuleListItemLoader';
import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
@ -123,7 +120,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
<GrafanaRuleLoader
key={key}
rule={rule}
groupName={groupIdentifier.groupName}
groupIdentifier={groupIdentifier}
namespaceName={ruleWithOrigin.namespaceName}
/>
);
@ -149,21 +146,11 @@ function FilterViewResults({ filterState }: FilterViewProps) {
</Text>
</Card>
)}
{!doneSearching && <LoadMoreHelper handleLoad={loadResultPage} />}
{!doneSearching && !loading && <LoadMoreHelper handleLoad={loadResultPage} />}
</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
function onFinished<T>(fn: () => void) {
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 { alertRuleApi } from '../api/alertRuleApi';
import { GrafanaRulesSource } from '../utils/datasource';
import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemLoader, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
interface GrafanaRuleLoaderProps {
rule: GrafanaPromRuleDTO;
groupName: string;
groupIdentifier: GrafanaRuleGroupIdentifier;
namespaceName: string;
}
export function GrafanaRuleLoader({ rule, groupName, namespaceName }: GrafanaRuleLoaderProps) {
const { folderUid } = rule;
export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) {
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 = {
name: rule.name,
name: title,
rulesSource: GrafanaRulesSource,
group: groupName,
group: groupIdentifier.groupName,
namespace: namespaceName,
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
health: rule.health,
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) {
return (
<AlertRuleListItem
{...commonProps}
application="grafana"
summary={rule.annotations?.summary}
summary={annotations.summary}
state={rule.state}
isProvisioned={undefined}
instancesCount={rule.alerts?.length}
/>
);
}
if (rule.type === PromRuleType.Recording) {
return <RecordingRuleListItem {...commonProps} application="grafana" isProvisioned={undefined} />;
return <RecordingRuleListItem {...commonProps} />;
}
return (
<UnknownRuleListItem
rule={rule}
groupIdentifier={{
rulesSource: GrafanaRulesSource,
groupName,
namespace: { uid: folderUid },
groupOrigin: 'grafana',
}}
/>
);
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
}

View File

@ -2,7 +2,7 @@ import { groupBy } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
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 { GrafanaRuleLoader } from './GrafanaRuleLoader';
@ -83,10 +83,25 @@ interface GrafanaRuleGroupListItemProps {
namespaceName: string;
}
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
const groupIdentifier: GrafanaRuleGroupIdentifier = {
groupName: group.name,
namespace: {
uid: group.folderUid,
},
groupOrigin: 'grafana',
};
return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{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>
);

View File

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

View File

@ -5,13 +5,7 @@ import { ReactNode, useEffect } 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 {
GrafanaRulesSourceSymbol,
Rule,
RuleGroupIdentifierV2,
RuleHealth,
RulesSourceIdentifier,
} from 'app/types/unified-alerting';
import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
@ -19,6 +13,7 @@ import { MetaText } from '../../components/MetaText';
import { ProvisioningBadge } from '../../components/Provisioning';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { getGroupOriginName } from '../../utils/groupIdentifier';
import { labelsSize } from '../../utils/labels';
import { createContactPointSearchLink } from '../../utils/misc';
import { RulePluginOrigin } from '../../utils/rules';
@ -268,12 +263,12 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
const styles = useStyles2(getStyles);
useEffect(() => {
const { rulesSource, namespace, groupName } = groupIdentifier;
const { namespace, groupName } = groupIdentifier;
const ruleContext = {
name: rule.name,
groupName,
namespace: JSON.stringify(namespace),
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
rulesSource: getGroupOriginName(groupIdentifier),
};
logError(new Error('unknown rule type'), ruleContext);
}, [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 Skeleton from 'react-loading-skeleton';
import { LinkButton, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
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 { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
import { useDispatch } from 'app/types';
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
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 { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
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.
// 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) {
const dispatch = useDispatch();
const redirectToListView = compact ? false : true;
const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView);
@ -45,8 +37,6 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
const { hasActiveFilters } = useRulesFilter();
const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance);
const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update);
@ -77,18 +67,9 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
promRule={promRule}
groupIdentifier={groupIdentifier}
identifier={identifier}
handleDelete={() => showDeleteModal(rule, groupIdentifier)}
handleDelete={() => showDeleteModal(identifier, groupIdentifier)}
handleSilence={() => setShowSilenceDrawer(true)}
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}
{isGrafanaAlertingRule(rule) && showSilenceDrawer && (
@ -104,5 +85,3 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
</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 { Icon, type IconName, Text, Tooltip } from '@grafana/ui';
@ -14,33 +15,34 @@ interface RuleListIconProps {
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
* 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,
health,
recording = false,
isPaused = false,
}: 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 iconColor: TextProps['color'] = state ? color[state] : 'secondary';
let stateName: string = state ? stateNames[state] : 'unknown';
@ -78,4 +80,4 @@ export function RuleListIcon({
</div>
</Tooltip>
);
}
});

View File

@ -19,7 +19,7 @@ import {
import { RulesFilter } from '../../search/rulesSearchParser';
import { labelsMatchMatchers } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
import { GrafanaRulesSource, getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
import { parseMatcher } from '../../utils/matchers';
import { isAlertingRule } from '../../utils/rules';
@ -109,7 +109,6 @@ function mapGrafanaRuleToRuleWithOrigin(
return {
rule,
groupIdentifier: {
rulesSource: GrafanaRulesSource,
namespace: { uid: group.folderUid },
groupName: group.name,
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 { isGrafanaRulerRule } from './rules';
@ -6,7 +6,6 @@ import { isGrafanaRulerRule } from './rules';
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
return {
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' },
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
groupName: rule.group.name,
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 = {
fromCombinedRule,
};

View File

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

View File

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

View File

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