Use Group Loader for the group detail view (#107780)

This commit is contained in:
Gilles De Mey
2025-07-09 15:41:20 +02:00
committed by GitHub
parent f34a9fc0c2
commit ded7912ea3
4 changed files with 209 additions and 211 deletions
@@ -1,13 +1,16 @@
import { HttpResponse } from 'msw';
import { uniqueId } from 'lodash';
import { HttpResponse, http } from 'msw';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, waitFor, within } from 'test/test-utils';
import { render, screen, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types/accessControl';
import { GrafanaPromRuleGroupDTO, GrafanaPromRulesResponse } from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions, mockRulerGrafanaRule, mockRulerRuleGroup } from '../mocks';
import { grantUserPermissions, mockGrafanaPromAlertingRule, mockRulerGrafanaRule, mockRulerRuleGroup } from '../mocks';
import {
mimirDataSource,
setFolderResponse,
@@ -38,8 +41,8 @@ const ui = {
header: byRole('heading', { level: 1 }),
editLink: byRole('link', { name: 'Edit' }),
exportButton: byRole('button', { name: 'Export' }),
tableRow: byTestId('row'),
rowsTable: byTestId('dynamic-table'),
ruleLoader: byTestId('alert-rule-list-item-loader'),
ruleItem: byRole('treeitem'),
export: {
dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }),
jsonTab: byRole('tab', { name: /JSON/ }),
@@ -50,10 +53,16 @@ const ui = {
},
};
setupMswServer();
const server = setupMswServer();
describe('GroupDetailsPage', () => {
beforeEach(() => {
// mock this...
setPluginLinksHook(() => ({
links: [],
isLoading: false,
}));
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
@@ -63,25 +72,71 @@ describe('GroupDetailsPage', () => {
});
describe('Grafana managed rules', () => {
const rule1 = mockRulerGrafanaRule({ for: '10m' }, { title: 'High CPU Usage' });
const rule2 = mockRulerGrafanaRule({ for: '5m' }, { title: 'Memory Pressure' });
const provisionedRule = mockRulerGrafanaRule({ for: '10m' }, { title: 'Provisioned Rule', provenance: 'api' });
const folder = { uid: 'test-folder-uid', canSave: true, title: 'test-folder-title' } as const;
const rule1 = mockRulerGrafanaRule({ for: '10m' }, { title: 'High CPU Usage', uid: uniqueId() });
const rule2 = mockRulerGrafanaRule({ for: '5m' }, { title: 'Memory Pressure', uid: uniqueId() });
const provisionedRule = mockRulerGrafanaRule(
{ for: '10m' },
{ uid: uniqueId(), title: 'Provisioned Rule', provenance: 'api' }
);
const group = mockRulerRuleGroup({
name: 'test-group-cpu',
interval: '3m',
rules: [rule1, rule2],
});
const promGroup: GrafanaPromRuleGroupDTO = {
name: group.name,
rules: [
mockGrafanaPromAlertingRule({
uid: rule1.grafana_alert.uid,
name: rule1.grafana_alert.title,
}),
mockGrafanaPromAlertingRule({
uid: rule2.grafana_alert.uid,
name: rule2.grafana_alert.title,
}),
],
interval: 180,
folderUid: folder.uid,
file: folder.title,
};
const provisionedGroup = mockRulerRuleGroup({
name: 'provisioned-group-cpu',
interval: '15m',
rules: [provisionedRule],
});
const promProvisionedGroup: GrafanaPromRuleGroupDTO = {
name: provisionedGroup.name,
interval: 900,
rules: [
mockGrafanaPromAlertingRule({
uid: provisionedRule.grafana_alert.uid,
name: provisionedRule.grafana_alert.title,
provenance: provisionedRule.grafana_alert.provenance,
}),
],
folderUid: folder.uid,
file: folder.title,
};
beforeEach(() => {
setRulerRuleGroupHandler({ response: HttpResponse.json(group) });
setFolderResponse({ uid: 'test-folder-uid', canSave: true, title: 'test-folder-title' });
server.use(
http.get('/api/prometheus/grafana/api/v1/rules', () =>
HttpResponse.json<GrafanaPromRulesResponse>({
status: 'success',
data: {
groups: [promGroup],
},
})
)
);
setFolderResponse(folder);
setGrafanaRuleGroupExportResolver(({ request }) => {
const url = new URL(request.url);
return HttpResponse.text(
@@ -106,22 +161,24 @@ describe('GroupDetailsPage', () => {
'/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fgrafana%2Fnamespaces%2Ftest-folder-uid%2Fgroups%2Ftest-group-cpu%2Fview'
);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
expect(tableRows).toHaveLength(2);
await waitForElementToBeRemoved(() => ui.ruleLoader.queryAll());
expect(within(tableRows[0]).getByRole('link', { name: rule1.grafana_alert.title })).toHaveAttribute(
const alertRuleItems = await ui.ruleItem.findAll();
expect(alertRuleItems).toHaveLength(2);
// assert rule 1
expect(within(alertRuleItems[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(alertRuleItems[0]).toHaveTextContent(rule1.grafana_alert.title);
expect(within(tableRows[1]).getByRole('link', { name: rule2.grafana_alert.title })).toHaveAttribute(
// assert rule 2
expect(within(alertRuleItems[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');
expect(alertRuleItems[1]).toHaveTextContent(rule2.grafana_alert.title);
});
it('should render error alert when API returns an error', async () => {
@@ -164,10 +221,14 @@ describe('GroupDetailsPage', () => {
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
// wait for loaders to show and dissapear
await waitFor(() => expect(ui.ruleLoader.queryAll()).toHaveLength(3));
await waitForElementToBeRemoved(() => ui.ruleLoader.queryAll());
const alertRuleItems = await ui.ruleItem.findAll();
// Assert
expect(tableRows).toHaveLength(2);
expect(alertRuleItems).toHaveLength(2);
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
});
@@ -177,24 +238,41 @@ describe('GroupDetailsPage', () => {
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
// wait for loaders to show and dissapear
await waitFor(() => expect(ui.ruleLoader.queryAll()).toHaveLength(3));
await waitForElementToBeRemoved(() => ui.ruleLoader.queryAll());
const alertRuleItems = await ui.ruleItem.findAll();
// Assert
expect(tableRows).toHaveLength(2);
expect(alertRuleItems).toHaveLength(2);
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
});
it('should not allow editing if the group is provisioned', async () => {
setRulerRuleGroupHandler({ response: HttpResponse.json(provisionedGroup) });
server.use(
http.get('/api/prometheus/grafana/api/v1/rules', () =>
HttpResponse.json<GrafanaPromRulesResponse>({
status: 'success',
data: {
groups: [promProvisionedGroup],
},
})
)
);
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', provisionedGroup.name);
// wait for loaders to show and dissapear
await waitFor(() => expect(ui.ruleLoader.queryAll()).toHaveLength(3));
await waitForElementToBeRemoved(() => ui.ruleLoader.queryAll());
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
const alertRuleItems = await ui.ruleItem.findAll();
// Assert
expect(tableRows).toHaveLength(1);
expect(tableRows[0]).toHaveTextContent('Provisioned Rule');
expect(alertRuleItems).toHaveLength(1);
expect(alertRuleItems[0]).toHaveTextContent('Provisioned Rule');
expect(ui.editLink.query()).not.toBeInTheDocument();
expect(ui.exportButton.query()).toBeInTheDocument();
});
@@ -1,35 +1,28 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { Trans, t } from '@grafana/i18n';
import { Alert, Badge, Button, LinkButton, Text, TextLink, withErrorBoundary } from '@grafana/ui';
import { Alert, Button, Dropdown, Icon, LinkButton, Menu, TextLink, withErrorBoundary } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { FolderDTO } from 'app/types/folders';
import { GrafanaRulesSourceSymbol, RuleGroup } from 'app/types/unified-alerting';
import { PromRuleType, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { RulesSourceFeatures, featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { DynamicTable, DynamicTableColumnProps } from '../components/DynamicTable';
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 { DataSourceGroupLoader } from '../rule-list/DataSourceGroupLoader';
import { GrafanaGroupLoader } from '../rule-list/GrafanaGroupLoader';
import { useRulesAccess } from '../utils/accessControlHooks';
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,
isFederatedRuleGroup,
isProvisionedRuleGroup,
rulerRuleType,
} from '../utils/rules';
import { formatPrometheusDuration, safeParsePrometheusDuration } from '../utils/time';
import { isFederatedRuleGroup, isProvisionedRuleGroup } from '../utils/rules';
import { formatPrometheusDuration } from '../utils/time';
import { Title } from './Title';
@@ -111,6 +104,7 @@ function GroupDetailsPage() {
},
}}
renderTitle={(title) => <Title name={title} />}
subTitle={t('alerting.titles.group-view.subtitle', 'Manage alert rules, recording rules and evaluation interval')}
info={[
{ label: namespaceLabel, value: namespaceValue },
{ label: t('alerting.group-details.interval', 'Interval'), value: groupInterval },
@@ -150,13 +144,33 @@ function GroupDetailsPage() {
<div>{stringifyErrorLike(ruleNamespacesError || ruleGroupError)}</div>
</Alert>
)}
{promGroup && ruleSourceName && (
<GroupDetails group={promRuleGroupToRuleGroupDetails(ruleSourceName, namespaceName, promGroup)} />
)}
{rulerGroup && ruleSourceName && (
<GroupDetails group={rulerRuleGroupToRuleGroupDetails(ruleSourceName, namespaceName, rulerGroup)} />
)}
{!promGroup && !rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />}
{ruleSourceName && (
<ul role="tree">
{isGrafanaRuleGroup ? (
<GrafanaGroupLoader
groupIdentifier={{ groupName, groupOrigin: 'grafana', namespace: { uid: namespaceId } }}
namespaceName={namespaceName}
/>
) : (
<DataSourceGroupLoader
groupIdentifier={{
groupName,
groupOrigin: 'datasource',
namespace: {
name: namespaceName,
},
rulesSource: {
name: ruleSourceName,
uid: dataSourceUid,
ruleSourceType: 'datasource',
},
}}
/>
)}
</ul>
)}
</>
</AlertingPageWrapper>
);
@@ -195,13 +209,39 @@ function GroupActions({ dsFeatures, namespaceId, groupName, folder, rulerGroup }
</Button>
)}
{canEdit && (
<LinkButton
icon="pen"
href={groups.editPageLink(dsFeatures.uid, namespaceId, groupName, { includeReturnTo: true })}
variant="secondary"
>
<Trans i18nKey="alerting.group-details.edit">Edit</Trans>
</LinkButton>
<>
<LinkButton
icon="pen"
href={groups.editPageLink(dsFeatures.uid, namespaceId, groupName, { includeReturnTo: true })}
variant="secondary"
>
<Trans i18nKey="alerting.group-details.edit">Edit</Trans>
</LinkButton>
{/* Data source managed requires different URLs, a hassle to implement for now */}
{isGrafanaSource && (
<Dropdown
overlay={
<Menu>
<Menu.Item
icon="bell"
url={groups.newAlertRuleLink(folder?.title, folder?.uid, groupName)}
label={t('alerting.alert-rule.term', 'Alert rule')}
/>
<Menu.Item
icon="record-audio"
url={groups.newRecordingRuleLink(folder?.title, folder?.uid, groupName)}
label={t('alerting.recording-rule.term', 'Recording rule')}
/>
</Menu>
}
>
<Button variant="primary">
{t('alerting.group-details.new', 'New')}
<Icon name="angle-down" />
</Button>
</Dropdown>
)}
</>
)}
{folder && isExporting && (
<GrafanaRuleGroupExporter folderUid={folder.uid} groupName={groupName} onClose={() => setIsExporting(false)} />
@@ -210,158 +250,4 @@ function GroupActions({ dsFeatures, namespaceId, groupName, folder, rulerGroup }
);
}
/** An common interface for both Prometheus and Ruler rule groups */
interface RuleGroupDetails {
name: string;
interval: string;
rules: RuleDetails[];
}
interface AlertingRuleDetails {
name: string;
href?: string;
type: 'alerting';
pendingPeriod: string;
evaluationsToFire: number;
}
interface RecordingRuleDetails {
name: string;
href?: string;
type: 'recording';
}
type RuleDetails = AlertingRuleDetails | RecordingRuleDetails;
interface GroupDetailsProps {
group: RuleGroupDetails;
}
function GroupDetails({ group }: GroupDetailsProps) {
return (
<div>
<RulesTable rules={group.rules} />
</div>
);
}
function RulesTable({ rules }: { rules: RuleDetails[] }) {
const rows = rules.map((rule: RuleDetails, index) => ({
id: index,
data: rule,
}));
const columns: Array<DynamicTableColumnProps<RuleDetails>> = useMemo(() => {
return [
{
id: 'alertName',
label: t('alerting.group-details.rule-name', 'Rule name'),
renderCell: ({ data: { name, href } }) => {
if (href) {
return (
<TextLink href={href} inline={false} color="primary">
{name}
</TextLink>
);
}
return <Text truncate>{name}</Text>;
},
size: 0.4,
},
{
id: 'for',
label: t('alerting.group-details.pending-period', 'Pending period'),
renderCell: ({ data }) => {
switch (data.type) {
case 'alerting':
return <>{data.pendingPeriod}</>;
case 'recording':
return <Badge text={t('alerting.group-details.recording', 'Recording')} color="purple" />;
}
},
size: 0.3,
},
{
id: 'numberEvaluations',
label: t('alerting.group-details.evaluations-to-fire', 'Evaluation cycles to fire'),
renderCell: ({ data }) => {
switch (data.type) {
case 'alerting':
return <>{data.evaluationsToFire}</>;
case 'recording':
return null;
}
},
size: 0.3,
},
];
}, []);
return <DynamicTable items={rows} cols={columns} />;
}
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, href, type: 'recording' };
}
}),
};
}
function rulerRuleGroupToRuleGroupDetails(
ruleSourceName: string,
namespaceName: string,
group: RulerRuleGroupDTO
): RuleGroupDetails {
const groupIntervalMs = safeParsePrometheusDuration(group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
return {
name: group.name,
interval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
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(
rule.for ? safeParsePrometheusDuration(rule.for) : 0,
groupIntervalMs
),
};
}
return { name, href, type: 'recording' };
}),
};
}
export default withErrorBoundary(GroupDetailsPage, { style: 'page' });
@@ -45,6 +45,32 @@ export const groups = {
{ skipSubPath: options?.skipSubPath }
);
},
newAlertRuleLink: (folderName?: string, folderUid?: string, groupName?: string) => {
const returnTo = createReturnTo();
const defaults = JSON.stringify({
folder: {
title: folderName,
uid: folderUid,
},
group: groupName,
});
return createRelativeUrl('/alerting/new', { defaults, returnTo });
},
newRecordingRuleLink: (folderName?: string, folderUid?: string, groupName?: string) => {
const returnTo = createReturnTo();
const defaults = JSON.stringify({
folder: {
title: folderName,
uid: folderUid,
},
group: groupName,
});
return createRelativeUrl('/alerting/new/grafana-recording', { defaults, returnTo });
},
};
export const rulesNav = {
+12 -4
View File
@@ -508,6 +508,9 @@
}
}
},
"alert-rule": {
"term": "Alert rule"
},
"alert-rule-form": {
"action-buttons": {
"edit-yaml": "Edit YAML",
@@ -1505,15 +1508,12 @@
"group-details": {
"ds-features-error": "Error loading data source details",
"edit": "Edit",
"evaluations-to-fire": "Evaluation cycles to fire",
"export": "Export",
"folder": "Folder",
"group-loading-error": "Error loading the group",
"interval": "Interval",
"namespace": "Namespace",
"pending-period": "Pending period",
"recording": "Recording",
"rule-name": "Rule name"
"new": "New"
},
"group-edit": {
"ds-error": "Error loading data source details",
@@ -2237,6 +2237,9 @@
"label-export-all": "Export all"
}
},
"recording-rule": {
"term": "Recording rule"
},
"recording-rule-editor": {
"error-no-query-editor": "Could not load query editor due to: {{errorMessage}}"
},
@@ -2890,6 +2893,11 @@
"timestamp": {
"time-ago": "({{time}} ago)"
},
"titles": {
"group-view": {
"subtitle": "Manage alert rules, recording rules and evaluation interval"
}
},
"to-gma": {
"confirm-modal": {
"body": "The target folder is not empty, some rules may be overwritten or removed. Are you sure you want to import these alert rules to Grafana-managed rules?",