Compare commits

...

9 Commits

Author SHA1 Message Date
rodrigopk
4cd3e32a96 Fix unused import in test 2026-01-06 16:16:41 -05:00
rodrigopk
1cbe2bad7c Revert unrelated eslint-suppressions.json change 2026-01-06 16:00:12 -05:00
rodrigopk
c790bdff06 Add translations 2026-01-06 15:56:14 -05:00
rodrigopk
aeba2401a9 Handle unsafe array access 2026-01-06 15:46:28 -05:00
rodrigopk
7af42967ee Add tests and use isUngroupedRuleGroup in RuleLocation 2026-01-06 15:27:28 -05:00
rodrigopk
45d7169f8b Add tests and use isUngroupedRuleGroup in PaginatedGrafanaLoader 2026-01-06 14:58:35 -05:00
rodrigopk
138000a80d Extract utility method isUngroupedRuleGroup 2026-01-06 14:34:30 -05:00
rodrigopk
35b43aae84 Add tests for ungrouped rules 2026-01-06 14:19:07 -05:00
Moustafa Baiou
9e40214c84 WIP: Update rules UI components to support no group rules
https://github.com/grafana/alerting-squad/issues/1215
2025-12-05 14:45:28 -05:00
10 changed files with 296 additions and 11 deletions

View File

@@ -4677,4 +4677,4 @@
"count": 1
}
}
}
}

View File

@@ -177,4 +177,32 @@ describe('Rules group tests', () => {
expect(ui.editGroupButton.query()).not.toBeInTheDocument();
});
});
describe('Ungrouped rules', () => {
const ungroupedGroup: CombinedRuleGroup = {
name: 'no_group_for_rule_TestRule',
rules: [
mockCombinedRule({
name: 'TestRule',
rulerRule: mockGrafanaRulerRule({ namespace_uid: 'folder-123' }),
}),
],
totals: {},
};
const namespace: CombinedRuleNamespace = {
name: 'TestNamespace',
rulesSource: 'grafana',
groups: [ungroupedGroup],
};
beforeEach(() => {
mockUseHasRuler(true, GRAFANA_RULER_CONFIG);
});
it('Should display rule name with (Ungrouped) suffix in grouped view', async () => {
renderRulesGroup(namespace, ungroupedGroup);
expect(await screen.findByText(/TestRule \(Ungrouped\)/)).toBeInTheDocument();
});
});
});

View File

@@ -13,7 +13,7 @@ import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink } from '../../utils/misc';
import { groups } from '../../utils/navigation';
import { isFederatedRuleGroup, isPluginProvidedRule, rulerRuleType } from '../../utils/rules';
import { isFederatedRuleGroup, isPluginProvidedRule, isUngroupedRuleGroup, rulerRuleType } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
@@ -164,11 +164,16 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
}
// ungrouped rules are rules that are in the "default" group name
const groupName = isListView ? (
<RuleLocation namespace={decodeGrafanaNamespace(namespace).name} />
) : (
<RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={group.name} />
);
let groupName = <RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={group.name} />;
if (isListView) {
groupName = <RuleLocation namespace={decodeGrafanaNamespace(namespace).name} />;
} else if (isUngroupedRuleGroup(group.name)) {
const firstRuleName = group.rules[0]?.name ?? t('alerting.rules-group.unknown-rule', 'Unknown Rule');
const groupDisplayName = t('alerting.rules-group.ungrouped-suffix', '{{ruleName}} (Ungrouped)', {
ruleName: firstRuleName,
});
groupName = <RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={groupDisplayName} />;
}
return (
<div className={styles.wrapper} data-testid="rule-group">

View File

@@ -0,0 +1,98 @@
import { render, screen } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
import { AccessControlAction } from 'app/types/accessControl';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { mockFolderApi, setupMswServer } from '../mockApi';
import { grantUserPermissions, mockFolder, mockGrafanaPromAlertingRule } from '../mocks';
import { NO_GROUP_PREFIX } from '../utils/rules';
import { GrafanaRuleGroupListItem } from './PaginatedGrafanaLoader';
const server = setupMswServer();
const ui = {
treeItem: byRole('treeitem'),
groupLink: (name: string | RegExp) => byRole('link', { name }),
ungroupedText: byText(/\(Ungrouped\)/),
};
describe('GrafanaRuleGroupListItem', () => {
beforeEach(() => {
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
mockFolderApi(server).folder('folder-123', mockFolder({ uid: 'folder-123', title: 'TestFolder' }));
});
afterEach(() => {
server.resetHandlers();
});
it('should display rule name with (Ungrouped) suffix for ungrouped rules', async () => {
const grafanaRule = mockGrafanaPromAlertingRule({ name: 'My Alert Rule' });
const ungroupedGroup: GrafanaPromRuleGroupDTO = {
name: `${NO_GROUP_PREFIX}test-rule-uid`,
file: 'TestFolder',
folderUid: 'folder-123',
interval: 60,
rules: [grafanaRule],
};
render(<GrafanaRuleGroupListItem group={ungroupedGroup} namespaceName="TestFolder" />);
expect(await ui.treeItem.find()).toBeInTheDocument();
expect(await ui.groupLink(/My Alert Rule \(Ungrouped\)/).find()).toBeInTheDocument();
});
it('should display normal group name for grouped rules', async () => {
const grafanaRule = mockGrafanaPromAlertingRule({ name: 'My Alert Rule' });
const groupedGroup: GrafanaPromRuleGroupDTO = {
name: 'MyGroup',
file: 'TestFolder',
folderUid: 'folder-123',
interval: 60,
rules: [grafanaRule],
};
render(<GrafanaRuleGroupListItem group={groupedGroup} namespaceName="TestFolder" />);
expect(await ui.groupLink('MyGroup').find()).toBeInTheDocument();
expect(screen.queryByText(/Ungrouped/)).not.toBeInTheDocument();
});
it('should render link to group details page with correct URL', async () => {
const grafanaRule = mockGrafanaPromAlertingRule({ name: 'My Alert Rule' });
const groupedGroup: GrafanaPromRuleGroupDTO = {
name: 'MyGroup',
file: 'TestFolder',
folderUid: 'folder-123',
interval: 60,
rules: [grafanaRule],
};
render(<GrafanaRuleGroupListItem group={groupedGroup} namespaceName="TestFolder" />);
const link = await ui.groupLink('MyGroup').find();
expect(link).toHaveAttribute(
'href',
expect.stringContaining('/alerting/grafana/namespaces/folder-123/groups/MyGroup/view')
);
});
it('should render as treeitem with correct aria attributes', async () => {
const grafanaRule = mockGrafanaPromAlertingRule({ name: 'My Alert Rule' });
const group: GrafanaPromRuleGroupDTO = {
name: 'TestGroup',
file: 'TestFolder',
folderUid: 'folder-123',
interval: 60,
rules: [grafanaRule],
};
render(<GrafanaRuleGroupListItem group={group} namespaceName="TestFolder" />);
const treeItem = await ui.treeItem.find();
expect(treeItem).toHaveAttribute('aria-expanded', 'false');
expect(treeItem).toHaveAttribute('aria-selected', 'false');
});
});

View File

@@ -1,6 +1,7 @@
import { groupBy, isEmpty } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { t } from '@grafana/i18n';
import { Icon, Stack, Text } from '@grafana/ui';
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
@@ -9,6 +10,7 @@ import { FolderActionsButton } from '../components/folder-actions/FolderActionsB
import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { groups } from '../utils/navigation';
import { isUngroupedRuleGroup } from '../utils/rules';
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
import { DataSourceSection } from './components/DataSourceSection';
@@ -161,10 +163,15 @@ export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGr
const detailsLink = groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name);
const firstRuleName = group.rules[0]?.name ?? t('alerting.rules-group.unknown-rule', 'Unknown Rule');
const groupDisplayName = isUngroupedRuleGroup(group.name)
? t('alerting.rules-group.ungrouped-suffix', '{{ruleName}} (Ungrouped)', { ruleName: firstRuleName })
: group.name;
return (
<ListGroup
key={group.name}
name={group.name}
name={groupDisplayName}
metaRight={<GroupIntervalIndicator seconds={group.interval} />}
href={detailsLink}
isOpen={false}

View File

@@ -0,0 +1,117 @@
import { render, screen } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { PromApplication } from 'app/types/unified-alerting-dto';
import { NO_GROUP_PREFIX } from '../../utils/rules';
import { RuleLocation } from './RuleLocation';
const ui = {
groupLink: (name: string) => byRole('link', { name }),
};
describe('RuleLocation', () => {
describe('ungrouped rules', () => {
it('should display "Ungrouped" text for groups with no_group_for_rule_ prefix', () => {
const { container } = render(
<RuleLocation namespace="TestNamespace" group={`${NO_GROUP_PREFIX}test-rule-uid`} application="grafana" />
);
expect(container).toHaveTextContent('Ungrouped');
expect(container).not.toHaveTextContent(`${NO_GROUP_PREFIX}test-rule-uid`);
});
it('should render "Ungrouped" as link when groupUrl is provided', () => {
render(
<RuleLocation
namespace="TestNamespace"
group={`${NO_GROUP_PREFIX}test-rule-uid`}
groupUrl="/alerting/grafana/namespaces/folder-123/groups/test-group/view"
application="grafana"
/>
);
const link = ui.groupLink('Ungrouped').get();
expect(link).toHaveAttribute('href', '/alerting/grafana/namespaces/folder-123/groups/test-group/view');
});
it('should render "Ungrouped" as text when groupUrl is not provided', () => {
const { container } = render(
<RuleLocation namespace="TestNamespace" group={`${NO_GROUP_PREFIX}test-rule-uid`} application="grafana" />
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(container).toHaveTextContent('Ungrouped');
});
});
describe('grouped rules', () => {
it('should display normal group name for regular groups', () => {
const { container } = render(<RuleLocation namespace="TestNamespace" group="MyGroup" application="grafana" />);
expect(container).toHaveTextContent('MyGroup');
expect(container).not.toHaveTextContent('Ungrouped');
});
it('should render group name as link when groupUrl is provided', () => {
render(
<RuleLocation
namespace="TestNamespace"
group="MyGroup"
groupUrl="/alerting/grafana/namespaces/folder-123/groups/MyGroup/view"
application="grafana"
/>
);
const link = ui.groupLink('MyGroup').get();
expect(link).toHaveAttribute('href', '/alerting/grafana/namespaces/folder-123/groups/MyGroup/view');
});
it('should render group name as text when groupUrl is not provided', () => {
const { container } = render(<RuleLocation namespace="TestNamespace" group="MyGroup" application="grafana" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(container).toHaveTextContent('MyGroup');
});
});
describe('namespace and group display', () => {
it('should display namespace and group correctly', () => {
const { container } = render(<RuleLocation namespace="TestNamespace" group="MyGroup" application="grafana" />);
expect(container).toHaveTextContent('TestNamespace');
expect(container).toHaveTextContent('MyGroup');
});
});
describe('grafana application', () => {
it('should not render data source tooltip for grafana application', () => {
render(<RuleLocation namespace="TestNamespace" group="MyGroup" application="grafana" />);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
describe('datasource application', () => {
const mockRulesSource = {
uid: 'prometheus-1',
name: 'Prometheus',
ruleSourceType: 'datasource' as const,
};
it('should render content for datasource application', () => {
const { container } = render(
<RuleLocation
namespace="TestNamespace"
group="MyGroup"
rulesSource={mockRulesSource}
application={PromApplication.Prometheus}
/>
);
expect(container).toHaveTextContent('TestNamespace');
expect(container).toHaveTextContent('MyGroup');
});
});
});

View File

@@ -1,7 +1,10 @@
import { t } from '@grafana/i18n';
import { Icon, Stack, TextLink, Tooltip } from '@grafana/ui';
import { RulesSourceIdentifier } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { isUngroupedRuleGroup } from '../../utils/rules';
import { DataSourceIcon } from './DataSourceIcon';
interface RuleLocationProps {
@@ -15,6 +18,7 @@ interface RuleLocationProps {
export function RuleLocation({ namespace, group, groupUrl, rulesSource, application }: RuleLocationProps) {
const isGrafanaApp = application === 'grafana';
const isDataSourceApp = !!rulesSource && !!application && !isGrafanaApp;
const groupText = isUngroupedRuleGroup(group) ? t('alerting.rules-group.ungrouped', 'Ungrouped') : group;
return (
<Stack direction="row" alignItems="center" gap={0.5}>
@@ -32,10 +36,10 @@ export function RuleLocation({ namespace, group, groupUrl, rulesSource, applicat
<Icon size="sm" name="angle-right" />
{groupUrl ? (
<TextLink href={groupUrl} color="secondary" variant="bodySmall" inline={false}>
{group}
{groupText}
</TextLink>
) : (
group
groupText
)}
</Stack>
</Stack>

View File

@@ -17,6 +17,7 @@ import {
getRuleGroupLocationFromCombinedRule,
getRuleGroupLocationFromRuleWithLocation,
getRulePluginOrigin,
isUngroupedRuleGroup,
} from './rules';
describe('getRuleOrigin', () => {
@@ -123,3 +124,22 @@ describe('ruleGroupLocation', () => {
});
});
});
describe('isUngroupedRuleGroup', () => {
it('should return true for group names starting with NO_GROUP_PREFIX', () => {
expect(isUngroupedRuleGroup('no_group_for_rule_abc123')).toBe(true);
expect(isUngroupedRuleGroup('no_group_for_rule_')).toBe(true);
expect(isUngroupedRuleGroup('no_group_for_rule_test-rule-uid')).toBe(true);
});
it('should return false for group names not starting with NO_GROUP_PREFIX', () => {
expect(isUngroupedRuleGroup('MyGroup')).toBe(false);
expect(isUngroupedRuleGroup('group-1')).toBe(false);
expect(isUngroupedRuleGroup('')).toBe(false);
});
it('should return false for group names that contain but do not start with NO_GROUP_PREFIX', () => {
expect(isUngroupedRuleGroup('prefix_no_group_for_rule_abc123')).toBe(false);
expect(isUngroupedRuleGroup('MyGroup_no_group_for_rule_')).toBe(false);
});
});

View File

@@ -563,3 +563,6 @@ export function getRuleUID(rule?: RulerRuleDTO | Rule) {
return ruleUid;
}
export const NO_GROUP_PREFIX = 'no_group_for_rule_';
export const isUngroupedRuleGroup = (group: string): boolean => group.startsWith(NO_GROUP_PREFIX);

View File

@@ -2656,7 +2656,10 @@
"rules-group": {
"deleting": "Deleting",
"text-federated": "Federated",
"text-provisioned": "Provisioned"
"text-provisioned": "Provisioned",
"ungrouped": "Ungrouped",
"ungrouped-suffix": "{{ruleName}} (Ungrouped)",
"unknown-rule": "Unknown Rule"
},
"search": {
"property": {