Alerting: Make rules clickable in rule group detail view (#105622)

This commit is contained in:
Gilles De Mey
2025-05-22 12:09:40 +02:00
committed by GitHub
parent ca0ac05b39
commit 30398b6591
3 changed files with 69 additions and 22 deletions

View File

@ -1,5 +1,3 @@
import { set } from 'lodash';
import { RelativeTimeRange } from '@grafana/data';
import { t } from '@grafana/i18n/internal';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
@ -49,11 +47,18 @@ export interface Datasource {
export const PREVIEW_URL = '/api/v1/rule/test/grafana';
export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules';
// for some reason vanilla Prometheus uses param notation with [] appended
export enum PrometheusAPIFilters {
RuleName = 'rule_name',
RuleNameVanilla = 'rule_name[]',
RuleGroup = 'rule_group',
RuleGroupVanilla = 'rule_group[]',
Namespace = 'file',
NamespaceVanilla = 'file[]',
FolderUID = 'folder_uid',
LimitAlerts = 'limit_alerts',
MaxGroups = 'max_groups',
ExcludeAlerts = 'exclude_alerts',
}
export interface Data {
@ -146,7 +151,9 @@ export const alertRuleApi = alertingApi.injectEndpoints({
if (identifier && (isPrometheusRuleIdentifier(identifier) || isCloudRuleIdentifier(identifier))) {
searchParams.set(PrometheusAPIFilters.Namespace, identifier.namespace);
searchParams.set(PrometheusAPIFilters.NamespaceVanilla, identifier.namespace);
searchParams.set(PrometheusAPIFilters.RuleGroup, identifier.groupName);
searchParams.set(PrometheusAPIFilters.RuleGroupVanilla, identifier.groupName);
}
const filterParams = getRulesFilterSearchParams(filter);
@ -193,22 +200,23 @@ export const alertRuleApi = alertingApi.injectEndpoints({
if (namespace) {
if (isGrafanaRulesSource(ruleSourceName)) {
set(queryParams, PrometheusAPIFilters.FolderUID, namespace);
queryParams[PrometheusAPIFilters.FolderUID] = namespace;
} else {
set(queryParams, PrometheusAPIFilters.Namespace, namespace);
queryParams[PrometheusAPIFilters.Namespace] = namespace;
queryParams[PrometheusAPIFilters.NamespaceVanilla] = namespace;
}
}
if (limitAlerts !== undefined) {
set(queryParams, PrometheusAPIFilters.LimitAlerts, String(limitAlerts));
queryParams[PrometheusAPIFilters.LimitAlerts] = String(PrometheusAPIFilters.LimitAlerts);
}
if (maxGroups) {
set(queryParams, 'max_groups', maxGroups);
queryParams[PrometheusAPIFilters.MaxGroups] = String(maxGroups);
}
if (excludeAlerts) {
set(queryParams, 'exclude_alerts', 'true');
queryParams[PrometheusAPIFilters.ExcludeAlerts] = 'true';
}
return {

View File

@ -1,7 +1,7 @@
import { HttpResponse } from 'msw';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, waitFor } from 'test/test-utils';
import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { AccessControlAction } from 'app/types';
@ -100,7 +100,7 @@ describe('GroupDetailsPage', () => {
// Assert
expect(header).toHaveTextContent('test-group-cpu');
expect(await screen.findByRole('link', { name: /test-folder-title/ })).toBeInTheDocument();
expect(await screen.findByText(/5m/)).toBeInTheDocument();
expect(await screen.findByText(group.interval!)).toBeInTheDocument();
expect(editLink).toHaveAttribute(
'href',
'/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fgrafana%2Fnamespaces%2Ftest-folder-uid%2Fgroups%2Ftest-group-cpu%2Fview'
@ -109,12 +109,18 @@ describe('GroupDetailsPage', () => {
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
expect(tableRows).toHaveLength(2);
expect(tableRows[0]).toHaveTextContent('High CPU Usage');
expect(tableRows[0]).toHaveTextContent('10m');
expect(within(tableRows[0]).getByRole('link', { name: rule1.grafana_alert.title })).toHaveAttribute(
'href',
`/alerting/grafana/${rule1.grafana_alert.uid}/view`
);
expect(tableRows[0]).toHaveTextContent(String(rule1.for));
expect(tableRows[0]).toHaveTextContent('5');
expect(tableRows[1]).toHaveTextContent('Memory Pressure');
expect(tableRows[1]).toHaveTextContent('5m');
expect(within(tableRows[1]).getByRole('link', { name: rule2.grafana_alert.title })).toHaveAttribute(
'href',
`/alerting/grafana/${rule2.grafana_alert.uid}/view`
);
expect(tableRows[1]).toHaveTextContent(String(rule2.for));
expect(tableRows[1]).toHaveTextContent('3');
});

View File

@ -16,10 +16,12 @@ import { DynamicTable, DynamicTableColumnProps } from '../components/DynamicTabl
import { GrafanaRuleGroupExporter } from '../components/export/GrafanaRuleGroupExporter';
import { useFolder } from '../hooks/useFolder';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults';
import { createViewLinkFromIdentifier } from '../rule-list/DataSourceRuleListItem';
import { useRulesAccess } from '../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME, getDataSourceByUid } from '../utils/datasource';
import { makeFolderLink, stringifyErrorLike } from '../utils/misc';
import { createListFilterLink, groups } from '../utils/navigation';
import { fromRule, fromRulerRule } from '../utils/rule-id';
import {
calcRuleEvalsToStartAlerting,
getRuleName,
@ -78,6 +80,7 @@ function GroupDetailsPage() {
);
const { t } = useTranslate();
const ruleSourceName = isGrafanaRuleGroup ? GRAFANA_RULES_SOURCE_NAME : getDataSourceByUid(dataSourceUid)?.name;
const isLoading = isFolderLoading || isDsFeaturesLoading || isRuleNamespacesLoading || isRuleGroupLoading;
const groupInterval = promGroup?.interval
@ -148,8 +151,12 @@ function GroupDetailsPage() {
<div>{stringifyErrorLike(ruleNamespacesError || ruleGroupError)}</div>
</Alert>
)}
{promGroup && <GroupDetails group={promRuleGroupToRuleGroupDetails(promGroup)} />}
{rulerGroup && <GroupDetails group={rulerRuleGroupToRuleGroupDetails(rulerGroup)} />}
{promGroup && ruleSourceName && (
<GroupDetails group={promRuleGroupToRuleGroupDetails(ruleSourceName, namespaceName, promGroup)} />
)}
{rulerGroup && ruleSourceName && (
<GroupDetails group={rulerRuleGroupToRuleGroupDetails(ruleSourceName, namespaceName, rulerGroup)} />
)}
{!promGroup && !rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />}
</>
</AlertingPageWrapper>
@ -213,12 +220,14 @@ interface RuleGroupDetails {
interface AlertingRuleDetails {
name: string;
href?: string;
type: 'alerting';
pendingPeriod: string;
evaluationsToFire: number;
}
interface RecordingRuleDetails {
name: string;
href?: string;
type: 'recording';
}
@ -248,8 +257,16 @@ function RulesTable({ rules }: { rules: RuleDetails[] }) {
{
id: 'alertName',
label: t('alerting.group-details.rule-name', 'Rule name'),
renderCell: ({ data }) => {
return <Text truncate>{data.name}</Text>;
renderCell: ({ data: { name, href } }) => {
if (href) {
return (
<TextLink href={href} inline={false} color="primary">
{name}
</TextLink>
);
}
return <Text truncate>{name}</Text>;
},
size: 0.4,
},
@ -285,29 +302,41 @@ function RulesTable({ rules }: { rules: RuleDetails[] }) {
return <DynamicTable items={rows} cols={columns} />;
}
function promRuleGroupToRuleGroupDetails(group: RuleGroup): RuleGroupDetails {
function promRuleGroupToRuleGroupDetails(
ruleSourceName: string,
namespaceName: string,
group: RuleGroup
): RuleGroupDetails {
const groupIntervalMs = group.interval * 1000;
return {
name: group.name,
interval: formatPrometheusDuration(group.interval * 1000),
rules: group.rules.map<RuleDetails>((rule) => {
const ruleIdentifier = fromRule(ruleSourceName, namespaceName, group.name, rule);
const href = ruleIdentifier ? createViewLinkFromIdentifier(ruleIdentifier) : undefined;
switch (rule.type) {
case PromRuleType.Alerting:
return {
name: rule.name,
href,
type: 'alerting',
pendingPeriod: formatPrometheusDuration(rule.duration ? rule.duration * 1000 : 0),
evaluationsToFire: calcRuleEvalsToStartAlerting(rule.duration ? rule.duration * 1000 : 0, groupIntervalMs),
};
case PromRuleType.Recording:
return { name: rule.name, type: 'recording' };
return { name: rule.name, href, type: 'recording' };
}
}),
};
}
function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDetails {
function rulerRuleGroupToRuleGroupDetails(
ruleSourceName: string,
namespaceName: string,
group: RulerRuleGroupDTO
): RuleGroupDetails {
const groupIntervalMs = safeParsePrometheusDuration(group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
return {
@ -316,9 +345,13 @@ function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDe
rules: group.rules.map<RuleDetails>((rule) => {
const name = getRuleName(rule);
const ruleIdentifier = fromRulerRule(ruleSourceName, namespaceName, group.name, rule);
const href = createViewLinkFromIdentifier(ruleIdentifier);
if (rulerRuleType.any.alertingRule(rule)) {
return {
name,
href,
type: 'alerting',
pendingPeriod: rule.for ?? '0s',
evaluationsToFire: calcRuleEvalsToStartAlerting(
@ -328,7 +361,7 @@ function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDe
};
}
return { name, type: 'recording' };
return { name, href, type: 'recording' };
}),
};
}