Alerting: Optimize rules fetching on the folder's rules tab (#104777)

* Mark labels and annotations as optional in Grafana ruler DTO

* Refactor AlertsFolderView to use folder-specific endpoint for rules loading

* Improve tests for BrowserFolderAlertingPage

* Update translations

* Revert go changes

* update gen files

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
pull/104579/head^2
Konrad Lalik 2 months ago committed by GitHub
parent a687c4a757
commit 13ebcf1d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 142
      public/app/features/alerting/unified/AlertsFolderView.test.tsx
  2. 65
      public/app/features/alerting/unified/AlertsFolderView.tsx
  3. 32
      public/app/features/alerting/unified/mocks/server/db.ts
  4. 6
      public/app/features/alerting/unified/mocks/server/handlers/folders.ts
  5. 3
      public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts
  6. 2
      public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx
  7. 2
      public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx
  8. 2
      public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx
  9. 92
      public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx
  10. 45
      public/app/features/browse-dashboards/BrowseFolderAlertingPage.tsx
  11. 4
      public/app/types/unified-alerting-dto.ts
  12. 4
      public/locales/en-US/grafana.json

@ -1,13 +1,9 @@
import { render } from 'test/test-utils'; import { render } from 'test/test-utils';
import { byTestId } from 'testing-library-selector'; import { byTestId } from 'testing-library-selector';
import { FolderState } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { AlertsFolderView } from './AlertsFolderView'; import { AlertsFolderView } from './AlertsFolderView';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { mockFolder } from './mocks';
import { mockCombinedRule } from './mocks'; import { alertingFactory } from './mocks/server/db';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
const ui = { const ui = {
filter: { filter: {
@ -19,122 +15,39 @@ const ui = {
}, },
}; };
const combinedNamespaceMock = jest.fn(useCombinedRuleNamespaces); const alertingRuleBuilder = alertingFactory.ruler.grafana.alertingRule;
jest.mock('./hooks/useCombinedRuleNamespaces', () => ({
useCombinedRuleNamespaces: () => combinedNamespaceMock(),
}));
const mockFolder = (folderOverride: Partial<FolderState> = {}): FolderState => {
return {
id: 1,
title: 'Folder with alerts',
uid: 'folder-1',
hasChanged: false,
canSave: false,
url: '/folder-1',
version: 1,
canDelete: false,
...folderOverride,
};
};
describe('AlertsFolderView tests', () => { describe('AlertsFolderView tests', () => {
it('Should display grafana alert rules when the folder uid matches the name space uid', () => { it('Should display grafana alert rules when the folder uid matches the name space uid', () => {
// Arrange // Arrange
const folder = mockFolder(); const folder = mockFolder();
const folderRules = alertingRuleBuilder.buildList(6);
const grafanaNamespace: CombinedRuleNamespace = {
name: folder.title,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
uid: 'folder-1',
groups: [
{
name: 'group1',
rules: [
mockCombinedRule({ name: 'Test Alert 1' }),
mockCombinedRule({ name: 'Test Alert 2' }),
mockCombinedRule({ name: 'Test Alert 3' }),
],
totals: {},
},
{
name: 'group2',
rules: [
mockCombinedRule({ name: 'Test Alert 4' }),
mockCombinedRule({ name: 'Test Alert 5' }),
mockCombinedRule({ name: 'Test Alert 6' }),
],
totals: {},
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act // Act
render(<AlertsFolderView folder={folder} />); render(<AlertsFolderView folder={folder} rules={folderRules} />);
// Assert // Assert
const alertRows = ui.ruleList.row.queryAll(); const alertRows = ui.ruleList.row.queryAll();
expect(alertRows).toHaveLength(6); expect(alertRows).toHaveLength(6);
expect(alertRows[0]).toHaveTextContent('Test Alert 1'); expect(alertRows[0]).toHaveTextContent('Alerting rule 1');
expect(alertRows[1]).toHaveTextContent('Test Alert 2'); expect(alertRows[1]).toHaveTextContent('Alerting rule 2');
expect(alertRows[2]).toHaveTextContent('Test Alert 3'); expect(alertRows[2]).toHaveTextContent('Alerting rule 3');
expect(alertRows[3]).toHaveTextContent('Test Alert 4'); expect(alertRows[3]).toHaveTextContent('Alerting rule 4');
expect(alertRows[4]).toHaveTextContent('Test Alert 5'); expect(alertRows[4]).toHaveTextContent('Alerting rule 5');
expect(alertRows[5]).toHaveTextContent('Test Alert 6'); expect(alertRows[5]).toHaveTextContent('Alerting rule 6');
});
it('Should not display alert rules when the namespace uid does not match the folder uid', () => {
// Arrange
const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = {
name: 'Folder without alerts',
rulesSource: GRAFANA_RULES_SOURCE_NAME,
uid: 'folder-2',
groups: [
{
name: 'default',
rules: [
mockCombinedRule({ name: 'Test Alert from other folder 1' }),
mockCombinedRule({ name: 'Test Alert from other folder 2' }),
],
totals: {},
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act
render(<AlertsFolderView folder={folder} />);
// Assert
expect(ui.ruleList.row.queryAll()).toHaveLength(0);
}); });
it('Should filter alert rules by the name, case insensitive', async () => { it('Should filter alert rules by the name, case insensitive', async () => {
// Arrange // Arrange
const folder = mockFolder(); const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = { const folderRules = [
name: folder.title, alertingRuleBuilder.build({ grafana_alert: { title: 'CPU Alert' } }),
rulesSource: GRAFANA_RULES_SOURCE_NAME, alertingRuleBuilder.build({ grafana_alert: { title: 'RAM usage alert' } }),
uid: 'folder-1', ];
groups: [
{
name: 'default',
rules: [mockCombinedRule({ name: 'CPU Alert' }), mockCombinedRule({ name: 'RAM usage alert' })],
totals: {},
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act // Act
const { user } = render(<AlertsFolderView folder={folder} />); const { user } = render(<AlertsFolderView folder={folder} rules={folderRules} />);
await user.type(ui.filter.name.get(), 'cpu'); await user.type(ui.filter.name.get(), 'cpu');
@ -147,26 +60,13 @@ describe('AlertsFolderView tests', () => {
// Arrange // Arrange
const folder = mockFolder(); const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = { const folderRules = [
name: folder.title, alertingRuleBuilder.build({ grafana_alert: { title: 'CPU Alert' }, labels: {} }),
rulesSource: GRAFANA_RULES_SOURCE_NAME, alertingRuleBuilder.build({ grafana_alert: { title: 'RAM usage alert' }, labels: { severity: 'critical' } }),
uid: 'folder-1', ];
groups: [
{
name: 'default',
rules: [
mockCombinedRule({ name: 'CPU Alert', labels: {} }),
mockCombinedRule({ name: 'RAM usage alert', labels: { severity: 'critical' } }),
],
totals: {},
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act // Act
const { user } = render(<AlertsFolderView folder={folder} />); const { user } = render(<AlertsFolderView folder={folder} rules={folderRules} />);
await user.type(ui.filter.label.get(), 'severity=critical'); await user.type(ui.filter.label.get(), 'severity=critical');

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useDebounce } from 'react-use'; import { useDebounce } from 'react-use';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
@ -8,20 +8,19 @@ import { Card, FilterInput, Icon, Pagination, Select, Stack, TagList, useStyles2
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { Trans, t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
import { getQueryParamValue } from 'app/core/utils/query'; import { getQueryParamValue } from 'app/core/utils/query';
import { FolderState, useDispatch } from 'app/types'; import { FolderDTO } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting'; import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { usePagination } from './hooks/usePagination'; import { usePagination } from './hooks/usePagination';
import { useURLSearchParams } from './hooks/useURLSearchParams'; import { useURLSearchParams } from './hooks/useURLSearchParams';
import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions';
import { combineMatcherStrings, labelsMatchMatchers } from './utils/alertmanager'; import { combineMatcherStrings, labelsMatchMatchers } from './utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers';
import { createViewLink } from './utils/misc'; import { rulesNav } from './utils/navigation';
interface Props { interface Props {
folder: FolderState; folder: FolderDTO;
rules: RulerGrafanaRuleDTO[];
} }
enum SortOrder { enum SortOrder {
@ -34,31 +33,20 @@ const sortOptions: Array<SelectableValue<SortOrder>> = [
{ label: 'Alphabetically [Z-A]', value: SortOrder.Descending }, { label: 'Alphabetically [Z-A]', value: SortOrder.Descending },
]; ];
export const AlertsFolderView = ({ folder }: Props) => { export const AlertsFolderView = ({ folder, rules }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const onTagClick = (tagName: string) => { const onTagClick = (tagName: string) => {
const matchersString = combineMatcherStrings(labelFilter, tagName); const matchersString = combineMatcherStrings(labelFilter, tagName);
setLabelFilter(matchersString); setLabelFilter(matchersString);
}; };
useEffect(() => {
dispatch(fetchPromRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
}, [dispatch]);
const combinedNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder } = const { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder } =
useAlertsFolderViewParams(); useAlertsFolderViewParams();
const matchingNamespace = combinedNamespaces.find((namespace) => namespace.uid === folder.uid); const filteredRules = filterAndSortRules(rules, nameFilter, labelFilter, sortOrder ?? SortOrder.Ascending);
const hasNoResults = filteredRules.length === 0;
const alertRules = matchingNamespace?.groups.flatMap((group) => group.rules) ?? [];
const filteredRules = filterAndSortRules(alertRules, nameFilter, labelFilter, sortOrder ?? SortOrder.Ascending);
const hasNoResults = alertRules.length === 0 || filteredRules.length === 0;
const { page, numberOfPages, onPageChange, pageItems } = usePagination(filteredRules, 1, DEFAULT_PER_PAGE_PAGINATION); const { page, numberOfPages, onPageChange, pageItems } = usePagination(filteredRules, 1, DEFAULT_PER_PAGE_PAGINATION);
return ( return (
@ -96,18 +84,18 @@ export const AlertsFolderView = ({ folder }: Props) => {
</Stack> </Stack>
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
{pageItems.map((currentRule) => ( {pageItems.map(({ grafana_alert, labels = {} }) => (
<Card <Card
key={Boolean(currentRule.uid) ? currentRule.uid : currentRule.name} key={grafana_alert.uid}
href={createViewLink('grafana', currentRule, '')} href={createGrafanaRuleViewLink(grafana_alert)}
className={styles.card} className={styles.card}
data-testid="alert-card-row" data-testid="alert-card-row"
> >
<Card.Heading>{currentRule.name}</Card.Heading> <Card.Heading>{grafana_alert.title}</Card.Heading>
<Card.Tags> <Card.Tags>
<TagList <TagList
onClick={onTagClick} onClick={onTagClick}
tags={Object.entries(currentRule.labels).map(([label, value]) => `${label}=${value}`)} tags={Object.entries(labels).map(([label, value]) => `${label}=${value}`)}
/> />
</Card.Tags> </Card.Tags>
<Card.Meta> <Card.Meta>
@ -178,17 +166,32 @@ function useAlertsFolderViewParams() {
} }
function filterAndSortRules( function filterAndSortRules(
originalRules: CombinedRule[], originalRules: RulerGrafanaRuleDTO[],
nameFilter: string, nameFilter: string,
labelFilter: string, labelFilter: string,
sortOrder: SortOrder sortOrder: SortOrder
) { ) {
const matchers = parsePromQLStyleMatcherLooseSafe(labelFilter); const matchers = parsePromQLStyleMatcherLooseSafe(labelFilter);
const rules = originalRules.filter( const rules = originalRules.filter((rule) => {
(rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers) const nameMatch = rule.grafana_alert.title.toLowerCase().includes(nameFilter.toLowerCase());
); const labelMatch = labelsMatchMatchers(rule.labels ?? {}, matchers);
return nameMatch && labelMatch;
});
return orderBy(rules, (rule) => rule.grafana_alert.title.toLowerCase(), [
sortOrder === SortOrder.Ascending ? 'asc' : 'desc',
]);
}
return orderBy(rules, (x) => x.name.toLowerCase(), [sortOrder === SortOrder.Ascending ? 'asc' : 'desc']); function createGrafanaRuleViewLink(ruleDefinition: GrafanaRuleDefinition): string {
return rulesNav.detailsPageLink(
GRAFANA_RULES_SOURCE_NAME,
{
uid: ruleDefinition.uid,
ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
},
undefined
);
} }
export const getStyles = (theme: GrafanaTheme2) => ({ export const getStyles = (theme: GrafanaTheme2) => ({

@ -6,6 +6,8 @@ import { config } from '@grafana/runtime';
import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types'; import { FolderDTO } from 'app/types';
import { import {
GrafanaAlertStateDecision,
GrafanaAlertingRuleDefinition,
GrafanaRecordingRuleDefinition, GrafanaRecordingRuleDefinition,
PromAlertingRuleDTO, PromAlertingRuleDTO,
PromAlertingRuleState, PromAlertingRuleState,
@ -14,11 +16,13 @@ import {
RulerAlertingRuleDTO, RulerAlertingRuleDTO,
RulerCloudRuleDTO, RulerCloudRuleDTO,
RulerGrafanaRuleDTO, RulerGrafanaRuleDTO,
RulerGrafanaRuleGroupDTO,
RulerRecordingRuleDTO, RulerRecordingRuleDTO,
RulerRuleGroupDTO, RulerRuleGroupDTO,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources'; import { setupDataSources } from '../../testSetup/datasources';
import { Annotation } from '../../utils/constants';
import { DataSourceType } from '../../utils/datasource'; import { DataSourceType } from '../../utils/datasource';
import { namespaces } from '../mimirRulerApi'; import { namespaces } from '../mimirRulerApi';
@ -95,6 +99,12 @@ const rulerGroupFactory = Factory.define<RulerRuleGroupDTO<RulerCloudRuleDTO>, {
} }
); );
const rulerGrafanaGroupFactory = Factory.define<RulerGrafanaRuleGroupDTO>(({ sequence }) => ({
name: `test-group-${sequence}`,
interval: '1m',
rules: grafanaAlertingRuleFactory.buildList(3),
}));
class DataSourceFactory extends Factory<DataSourceInstanceSettings> { class DataSourceFactory extends Factory<DataSourceInstanceSettings> {
vanillaPrometheus() { vanillaPrometheus() {
return this.params({ uid: PROMETHEUS_DATASOURCE_UID, name: 'Prometheus' }); return this.params({ uid: PROMETHEUS_DATASOURCE_UID, name: 'Prometheus' });
@ -179,6 +189,26 @@ const grafanaRecordingRule = Factory.define<RulerGrafanaRuleDTO<GrafanaRecording
annotations: {}, // @TODO recording rules don't have annotations, we need to fix this type definition annotations: {}, // @TODO recording rules don't have annotations, we need to fix this type definition
})); }));
const grafanaAlertingRuleFactory = Factory.define<RulerGrafanaRuleDTO<GrafanaAlertingRuleDefinition>>(
({ sequence }) => ({
grafana_alert: {
id: String(sequence),
uid: uniqueId(),
title: `Alerting rule ${sequence}`,
namespace_uid: 'test-namespace',
rule_group: 'test-group',
condition: 'A',
data: [],
is_paused: false,
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error,
} satisfies GrafanaAlertingRuleDefinition,
for: '5m',
labels: { 'label-key-1': 'label-value-1' },
annotations: { [Annotation.summary]: 'test alert' },
})
);
class GrafanaContactPointFactory extends Factory<GrafanaManagedContactPoint> { class GrafanaContactPointFactory extends Factory<GrafanaManagedContactPoint> {
withIntegrations(builder: (factory: GrafanaReceiverConfigFactory) => GrafanaManagedReceiverConfig[]) { withIntegrations(builder: (factory: GrafanaReceiverConfigFactory) => GrafanaManagedReceiverConfig[]) {
return this.params({ return this.params({
@ -282,7 +312,9 @@ export const alertingFactory = {
alertingRule: rulerAlertingRuleFactory, alertingRule: rulerAlertingRuleFactory,
recordingRule: rulerRecordingRuleFactory, recordingRule: rulerRecordingRuleFactory,
grafana: { grafana: {
group: rulerGrafanaGroupFactory,
recordingRule: grafanaRecordingRule, recordingRule: grafanaRecordingRule,
alertingRule: grafanaAlertingRuleFactory,
}, },
}, },
dataSource: dataSourceFactory, dataSource: dataSourceFactory,

@ -4,11 +4,11 @@ import { mockFolder } from 'app/features/alerting/unified/mocks';
import { grafanaRulerRule } from 'app/features/alerting/unified/mocks/grafanaRulerApi'; import { grafanaRulerRule } from 'app/features/alerting/unified/mocks/grafanaRulerApi';
import { FolderDTO } from 'app/types'; import { FolderDTO } from 'app/types';
const DEFAULT_FOLDERS: FolderDTO[] = [ export const DEFAULT_FOLDERS: FolderDTO[] = [
mockFolder({ mockFolder({
id: 1, id: 1,
uid: 'uid', uid: 'e3d1f4fd-9e7c-4f63-9a9e-2b5a1d2e6a9c',
title: 'title', title: 'Alerting-folder',
}), }),
mockFolder({ mockFolder({
id: 2, id: 2,

@ -180,6 +180,9 @@ export const rulerRuleVersionHistoryHandler = () => {
draft.grafana_alert.version = 2; draft.grafana_alert.version = 2;
draft.grafana_alert.updated = '2025-01-14T09:35:17.000Z'; draft.grafana_alert.updated = '2025-01-14T09:35:17.000Z';
draft.for = '2h'; draft.for = '2h';
if (!draft.labels) {
draft.labels = {};
}
draft.labels.foo = 'bar'; draft.labels.foo = 'bar';
draft.grafana_alert.notification_settings = { receiver: 'another receiver' }; draft.grafana_alert.notification_settings = { receiver: 'another receiver' };
draft.grafana_alert.updated_by = { draft.grafana_alert.updated_by = {

@ -134,7 +134,7 @@ describe('CloneRuleEditor', function () {
name: 'region: nasa', name: 'region: nasa',
}).get() }).get()
).toBeInTheDocument(); ).toBeInTheDocument();
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent(grafanaRulerRule.annotations[Annotation.summary]); expect(ui.inputs.annotationValue(0).get()).toHaveTextContent(grafanaRulerRule.annotations![Annotation.summary]);
}); });
}); });

@ -95,7 +95,7 @@ describe('RuleEditor grafana managed rules', () => {
expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title); expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title);
//check that folder is in the list //check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations?.[Annotation.summary]);
expect(screen.getByText('New folder')).toBeInTheDocument(); expect(screen.getByText('New folder')).toBeInTheDocument();
//check that slashed folders are not in the list //check that slashed folders are not in the list

@ -129,7 +129,7 @@ describe('RuleEditor grafana managed rules', () => {
expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title); expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title);
//check that folder is in the list //check that folder is in the list
await waitFor(() => expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title))); await waitFor(() => expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)));
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations?.[Annotation.summary]);
expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown

@ -1,44 +1,31 @@
import { render as rtlRender, screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { Chance } from 'chance'; import { render } from 'test/test-utils';
import { http, HttpResponse } from 'msw';
import { SetupServer, setupServer } from 'msw/node';
import { useParams } from 'react-router-dom-v5-compat';
import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { backendSrv } from 'app/core/services/backend_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { rulerTestDb } from '../alerting/unified/mocks/grafanaRulerApi';
import { alertingFactory } from '../alerting/unified/mocks/server/db';
import { DEFAULT_FOLDERS } from '../alerting/unified/mocks/server/handlers/folders';
import BrowseFolderAlertingPage from './BrowseFolderAlertingPage'; import BrowseFolderAlertingPage from './BrowseFolderAlertingPage';
import { getPrometheusRulesResponse, getRulerRulesResponse } from './fixtures/alertRules.fixture';
import * as permissions from './permissions'; import * as permissions from './permissions';
function render(...[ui, options]: Parameters<typeof rtlRender>) { // Use the folder and rules from the mocks
rtlRender(<TestProvider>{ui}</TestProvider>, options); const folder = DEFAULT_FOLDERS[0];
} const { uid: folderUid, title: folderTitle } = folder;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
config: {
...jest.requireActual('@grafana/runtime').config,
unifiedAlertingEnabled: true,
},
}));
jest.mock('react-router-dom-v5-compat', () => ({ jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'), ...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn(), useParams: jest.fn(() => ({ uid: folderUid })),
})); }));
const mockFolderName = 'myFolder';
const mockFolderUid = '12345';
const random = Chance(1); config.unifiedAlertingEnabled = true;
const rule_uid = random.guid();
const mockRulerRulesResponse = getRulerRulesResponse(mockFolderName, mockFolderUid, rule_uid); setupMswServer();
const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, mockFolderUid, rule_uid);
describe('browse-dashboards BrowseFolderAlertingPage', () => { describe('browse-dashboards BrowseFolderAlertingPage', () => {
(useParams as jest.Mock).mockReturnValue({ uid: mockFolderUid });
let server: SetupServer;
const mockPermissions = { const mockPermissions = {
canCreateDashboards: true, canCreateDashboards: true,
canEditDashboards: true, canEditDashboards: true,
@ -49,28 +36,6 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => {
canSetPermissions: true, canSetPermissions: true,
}; };
beforeAll(() => {
server = setupServer(
http.get('/api/folders/:uid', () => {
return HttpResponse.json({
title: mockFolderName,
uid: mockFolderUid,
});
}),
http.get('api/ruler/grafana/api/v1/rules', () => {
return HttpResponse.json(mockRulerRulesResponse);
}),
http.get('api/prometheus/grafana/api/v1/rules', () => {
return HttpResponse.json(mockPrometheusRulesResponse);
})
);
server.listen();
});
afterAll(() => {
server.close();
});
beforeEach(() => { beforeEach(() => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => mockPermissions); jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => mockPermissions);
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
@ -78,12 +43,11 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
server.resetHandlers();
}); });
it('displays the folder title', async () => { it('displays the folder title', async () => {
render(<BrowseFolderAlertingPage />); render(<BrowseFolderAlertingPage />);
expect(await screen.findByRole('heading', { name: mockFolderName })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: folderTitle })).toBeInTheDocument();
}); });
it('displays the "Folder actions" button', async () => { it('displays the "Folder actions" button', async () => {
@ -102,7 +66,7 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => {
}; };
}); });
render(<BrowseFolderAlertingPage />); render(<BrowseFolderAlertingPage />);
expect(await screen.findByRole('heading', { name: mockFolderName })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: folderTitle })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
}); });
@ -118,10 +82,24 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => {
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toHaveAttribute('aria-selected', 'true'); expect(await screen.findByRole('tab', { name: 'Alert rules' })).toHaveAttribute('aria-selected', 'true');
}); });
it('displays the alert rules returned by the API', async () => { it('displays rules from the folder', async () => {
const ruleUid = 'xYz1A2b3C4';
const group = alertingFactory.ruler.grafana.group.build({
name: 'test-group',
rules: [
alertingFactory.ruler.grafana.alertingRule.build({
grafana_alert: { title: 'Grafana-rule', namespace_uid: folderUid, rule_group: 'test-group', uid: ruleUid },
}),
],
});
rulerTestDb.addGroup(group, { name: folderTitle, uid: folderUid });
render(<BrowseFolderAlertingPage />); render(<BrowseFolderAlertingPage />);
const ruleName = mockPrometheusRulesResponse.data.groups[0].rules[0].name; expect(await screen.findByRole('heading', { name: folderTitle })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: ruleName })).toBeInTheDocument(); expect(await screen.findByRole('link', { name: 'Grafana-rule' })).toHaveAttribute(
'href',
`/alerting/grafana/${ruleUid}/view`
);
}); });
}); });

@ -1,19 +1,35 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom-v5-compat'; import { useParams } from 'react-router-dom-v5-compat';
import { Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { t } from 'app/core/internationalization';
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel'; import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
import { useSelector } from 'app/types';
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView'; import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
import { alertRuleApi } from '../alerting/unified/api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../alerting/unified/api/featureDiscoveryApi';
import { stringifyErrorLike } from '../alerting/unified/utils/misc';
import { rulerRuleType } from '../alerting/unified/utils/rules';
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI'; import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
import { FolderActionsButton } from './components/FolderActionsButton'; import { FolderActionsButton } from './components/FolderActionsButton';
const { useRulerNamespaceQuery } = alertRuleApi;
export function BrowseFolderAlertingPage() { export function BrowseFolderAlertingPage() {
const { uid: folderUID = '' } = useParams(); const { uid: folderUID = '' } = useParams();
const { data: folderDTO } = useGetFolderQuery(folderUID); const { data: folderDTO, isLoading: isFolderLoading } = useGetFolderQuery(folderUID);
const folder = useSelector((state) => state.folder);
const {
data: rulerNamespace = {},
isLoading: isRulerNamespaceLoading,
error: rulerNamespaceError,
} = useRulerNamespaceQuery({
rulerConfig: GRAFANA_RULER_CONFIG,
namespace: folderUID,
});
const [saveFolder] = useSaveFolderMutation(); const [saveFolder] = useSaveFolderMutation();
const navModel = useMemo(() => { const navModel = useMemo(() => {
@ -45,6 +61,12 @@ export function BrowseFolderAlertingPage() {
} }
: undefined; : undefined;
const isLoading = isFolderLoading || isRulerNamespaceLoading;
const folderRules = Object.values(rulerNamespace)
.flatMap((group) => group)
.flatMap((group) => group.rules)
.filter(rulerRuleType.grafana.rule);
return ( return (
<Page <Page
navId="dashboards/browse" navId="dashboards/browse"
@ -52,8 +74,21 @@ export function BrowseFolderAlertingPage() {
onEditTitle={onEditTitle} onEditTitle={onEditTitle}
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>} actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
> >
<Page.Contents> <Page.Contents isLoading={isLoading}>
<AlertsFolderView folder={folder} /> {!folderDTO && (
<Alert
title={t('browse-dashboards.browse-folder-alerting-page.title-folder-not-found', 'Folder not found')}
/>
)}
{!!rulerNamespaceError && (
<Alert
title={t('browse-dashboards.browse-folder-alerting-page.title-ruler-namespace-error', 'Cannot load rules')}
severity="error"
>
{stringifyErrorLike(rulerNamespaceError)}
</Alert>
)}
{folderDTO && <AlertsFolderView folder={folderDTO} rules={folderRules} />}
</Page.Contents> </Page.Contents>
</Page> </Page>
); );

@ -301,8 +301,8 @@ export interface RulerGrafanaRuleDTO<T = GrafanaRuleDefinition> {
grafana_alert: T; grafana_alert: T;
for?: string; for?: string;
keep_firing_for?: string; keep_firing_for?: string;
annotations: Annotations; annotations?: Annotations;
labels: Labels; labels?: Labels;
} }
export type TopLevelGrafanaRuleDTOField = keyof Omit<RulerGrafanaRuleDTO, 'grafana_alert'>; export type TopLevelGrafanaRuleDTOField = keyof Omit<RulerGrafanaRuleDTO, 'grafana_alert'>;

@ -2799,6 +2799,10 @@
"actions": { "actions": {
"button-to-recently-deleted": "Recently deleted" "button-to-recently-deleted": "Recently deleted"
}, },
"browse-folder-alerting-page": {
"title-folder-not-found": "Folder not found",
"title-ruler-namespace-error": "Cannot load rules"
},
"browse-view": { "browse-view": {
"this-folder-is-empty": "This folder is empty" "this-folder-is-empty": "This folder is empty"
}, },

Loading…
Cancel
Save