Alerting: Integrate alerts with folders (#45763)

* Add Alert rules tab

* Add pagination and a simple name-based filtering

* Add name and label based filtering

* Improve pagination, handle the no results case

* Add tests for alerts folder view

* Add label filtering by clicking a tag

* Add tests for matcher to string method

* Add sorting, improve styles

* Use simple Select component for sorting

* Update default page size

* Remove unused code

* Use existingc thunk

* chore: update swagger spec

* Revert "chore: update swagger spec"

This reverts commit ee79ec7341.

* Code cleanup

* Fix ts

Co-authored-by: gillesdemey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2022-03-14 15:21:29 +01:00 committed by GitHub
parent e512029afb
commit 2409405c34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 503 additions and 4 deletions

View File

@ -46,3 +46,13 @@ function getRefId(num: number): string {
return getRefId(Math.floor(num / letters.length) - 1) + letters[num % letters.length];
}
}
/**
* Returns the input value for non empty string and undefined otherwise
*
* It is inadvisable to set a query param to an empty string as it will be added to the URL.
* It is better to keep it undefined so the param won't be added to the URL at all.
*/
export function getQueryParamValue(value: string | undefined | null): string | undefined {
return value || undefined;
}

View File

@ -0,0 +1,189 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { configureStore } from 'app/store/configureStore';
import { FolderState } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { byTestId } from 'testing-library-selector';
import { AlertsFolderView } from './AlertsFolderView';
import { mockCombinedRule } from './mocks';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
const ui = {
filter: {
name: byTestId('name-filter'),
label: byTestId('label-filter'),
},
ruleList: {
row: byTestId('alert-card-row'),
},
};
const combinedNamespaceMock = jest.fn<CombinedRuleNamespace[], any>();
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,
permissions: [],
canViewFolderPermissions: false,
canDelete: false,
...folderOverride,
};
};
describe('AlertsFolderView tests', () => {
it('Should display grafana alert rules when the namespace name matches the folder name', () => {
// Arrange
const store = configureStore();
const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = {
name: folder.title,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'default',
rules: [
mockCombinedRule({ name: 'Test Alert 1' }),
mockCombinedRule({ name: 'Test Alert 2' }),
mockCombinedRule({ name: 'Test Alert 3' }),
],
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act
render(
<Provider store={store}>
<MemoryRouter>
<AlertsFolderView folder={folder} />
</MemoryRouter>
</Provider>
);
// Assert
const alertRows = ui.ruleList.row.queryAll();
expect(alertRows).toHaveLength(3);
expect(alertRows[0]).toHaveTextContent('Test Alert 1');
expect(alertRows[1]).toHaveTextContent('Test Alert 2');
expect(alertRows[2]).toHaveTextContent('Test Alert 3');
});
it('Shold not display alert rules when the namespace name does not match the folder name', () => {
// Arrange
const store = configureStore();
const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = {
name: 'Folder without alerts',
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'default',
rules: [
mockCombinedRule({ name: 'Test Alert from other folder 1' }),
mockCombinedRule({ name: 'Test Alert from other folder 2' }),
],
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act
render(
<Provider store={store}>
<MemoryRouter>
<AlertsFolderView folder={folder} />
</MemoryRouter>
</Provider>
);
// Assert
expect(ui.ruleList.row.queryAll()).toHaveLength(0);
});
it('Should filter alert rules by the name, case insensitive', () => {
// Arrange
const store = configureStore();
const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = {
name: folder.title,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'default',
rules: [mockCombinedRule({ name: 'CPU Alert' }), mockCombinedRule({ name: 'RAM usage alert' })],
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act
render(
<Provider store={store}>
<MemoryRouter>
<AlertsFolderView folder={folder} />
</MemoryRouter>
</Provider>
);
userEvent.type(ui.filter.name.get(), 'cpu');
// Assert
expect(ui.ruleList.row.queryAll()).toHaveLength(1);
expect(ui.ruleList.row.get()).toHaveTextContent('CPU Alert');
});
it('Should filter alert rule by labels', () => {
// Arrange
const store = configureStore();
const folder = mockFolder();
const grafanaNamespace: CombinedRuleNamespace = {
name: folder.title,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'default',
rules: [
mockCombinedRule({ name: 'CPU Alert', labels: {} }),
mockCombinedRule({ name: 'RAM usage alert', labels: { severity: 'critical' } }),
],
},
],
};
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
// Act
render(
<Provider store={store}>
<MemoryRouter>
<AlertsFolderView folder={folder} />
</MemoryRouter>
</Provider>
);
userEvent.type(ui.filter.label.get(), 'severity=critical');
// Assert
expect(ui.ruleList.row.queryAll()).toHaveLength(1);
expect(ui.ruleList.row.get()).toHaveTextContent('RAM usage alert');
});
});

View File

@ -0,0 +1,202 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Card, FilterInput, Icon, Pagination, Select, TagList, useStyles2 } from '@grafana/ui';
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { getQueryParamValue } from 'app/core/utils/query';
import { FolderState } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting';
import { isEqual, orderBy, uniqWith } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDebounce } from 'react-use';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { usePagination } from './hooks/usePagination';
import { useURLSearchParams } from './hooks/useURLSearchParams';
import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions';
import { labelsMatchMatchers, matchersToString, parseMatcher, parseMatchers } from './utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { createViewLink } from './utils/misc';
interface Props {
folder: FolderState;
}
enum SortOrder {
Ascending = 'alpha-asc',
Descending = 'alpha-desc',
}
const sortOptions: Array<SelectableValue<SortOrder>> = [
{ label: 'Alphabetically [A-Z]', value: SortOrder.Ascending },
{ label: 'Alphabetically [Z-A]', value: SortOrder.Descending },
];
export const AlertsFolderView = ({ folder }: Props) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const onTagClick = (tagName: string) => {
const matchers = parseMatchers(labelFilter);
const tagMatcherField = parseMatcher(tagName);
const uniqueMatchers = uniqWith([...matchers, tagMatcherField], isEqual);
const matchersString = matchersToString(uniqueMatchers);
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 } =
useAlertsFolderViewParams();
const matchingNamespace = combinedNamespaces.find((namespace) => namespace.name === folder.title);
const alertRules = matchingNamespace?.groups[0]?.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);
return (
<div className={styles.container}>
<Stack direction="column" gap={3}>
<FilterInput
value={nameFilter}
onChange={setNameFilter}
placeholder="Search alert rules by name"
data-testid="name-filter"
/>
<Stack direction="row">
<Select<SortOrder>
value={sortOrder}
onChange={({ value }) => value && setSortOrder(value)}
options={sortOptions}
width={25}
aria-label="Sort"
placeholder={`Sort (Default A-Z)`}
prefix={<Icon name={sortOrder === SortOrder.Ascending ? 'sort-amount-up' : 'sort-amount-down'} />}
/>
<FilterInput
value={labelFilter}
onChange={setLabelFilter}
placeholder="Search alerts by labels"
className={styles.filterLabelsInput}
data-testid="label-filter"
/>
</Stack>
<Stack gap={1}>
{pageItems.map((currentRule) => (
<Card
key={currentRule.name}
href={createViewLink('grafana', currentRule, '')}
className={styles.card}
data-testid="alert-card-row"
>
<Card.Heading>{currentRule.name}</Card.Heading>
<Card.Tags>
<TagList
onClick={onTagClick}
tags={Object.entries(currentRule.labels).map(([label, value]) => `${label}=${value}`)}
/>
</Card.Tags>
<Card.Meta>
<div>
<Icon name="folder" /> {folder.title}
</div>
</Card.Meta>
</Card>
))}
</Stack>
{hasNoResults && <div className={styles.noResults}>No alert rules found</div>}
<div className={styles.pagination}>
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage={true}
/>
</div>
</Stack>
</div>
);
};
enum AlertFolderViewParams {
nameFilter = 'nameFilter',
labelFilter = 'labelFilter',
sortOrder = 'sort',
}
function useAlertsFolderViewParams() {
const [searchParams, setSearchParams] = useURLSearchParams();
const [nameFilter, setNameFilter] = useState(searchParams.get(AlertFolderViewParams.nameFilter) ?? '');
const [labelFilter, setLabelFilter] = useState(searchParams.get(AlertFolderViewParams.labelFilter) ?? '');
const sortParam = searchParams.get(AlertFolderViewParams.sortOrder);
const [sortOrder, setSortOrder] = useState<SortOrder | undefined>(
sortParam === SortOrder.Ascending
? SortOrder.Ascending
: sortParam === SortOrder.Descending
? SortOrder.Descending
: undefined
);
useDebounce(
() =>
setSearchParams(
{
[AlertFolderViewParams.nameFilter]: getQueryParamValue(nameFilter),
[AlertFolderViewParams.labelFilter]: getQueryParamValue(labelFilter),
[AlertFolderViewParams.sortOrder]: getQueryParamValue(sortOrder),
},
true
),
400,
[nameFilter, labelFilter, sortOrder]
);
return { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder };
}
function filterAndSortRules(
originalRules: CombinedRule[],
nameFilter: string,
labelFilter: string,
sortOrder: SortOrder
) {
const matchers = parseMatchers(labelFilter);
let rules = originalRules.filter(
(rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers)
);
return orderBy(rules, (x) => x.name, [sortOrder === SortOrder.Ascending ? 'asc' : 'desc']);
}
export const getStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(1)};
`,
card: css`
grid-template-columns: auto 1fr 2fr;
margin: 0;
`,
pagination: css`
align-self: center;
`,
filterLabelsInput: css`
flex: 1;
width: auto;
min-width: 240px;
`,
noResults: css`
padding: ${theme.spacing(2)};
background-color: ${theme.colors.background.secondary};
font-style: italic;
`,
});

View File

@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
export function usePagination<T>(items: T[], initialPage: number, itemsPerPage: number) {
const [page, setPage] = useState(initialPage);
const numberOfPages = Math.ceil(items.length / itemsPerPage);
const firstItemOnPageIndex = itemsPerPage * (page - 1);
const pageItems = items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage);
const onPageChange = (newPage: number) => {
setPage(newPage);
};
// Reset the current page when number of changes has been changed
useEffect(() => setPage(1), [numberOfPages]);
return { page, onPageChange, numberOfPages, pageItems };
}

View File

@ -1,6 +1,12 @@
import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { parseMatcher, parseMatchers, labelsMatchMatchers, removeMuteTimingFromRoute } from './alertmanager';
import {
parseMatcher,
parseMatchers,
labelsMatchMatchers,
removeMuteTimingFromRoute,
matchersToString,
} from './alertmanager';
describe('Alertmanager utils', () => {
describe('parseMatcher', () => {
@ -162,4 +168,19 @@ describe('Alertmanager utils', () => {
});
});
});
describe('matchersToString', () => {
it('Should create a comma-separated list of labels and values wrapped into curly brackets', () => {
const matchers: Matcher[] = [
{ name: 'severity', value: 'critical', isEqual: true, isRegex: false },
{ name: 'resource', value: 'cpu', isEqual: true, isRegex: true },
{ name: 'rule_uid', value: '2Otf8canzz', isEqual: false, isRegex: false },
{ name: 'cluster', value: 'prom', isEqual: false, isRegex: true },
];
const matchersString = matchersToString(matchers);
expect(matchersString).toBe('{severity="critical",resource=~"cpu",rule_uid!="2Otf8canzz",cluster!~"prom"}');
});
});
});

View File

@ -104,6 +104,17 @@ export function matcherFieldToMatcher(field: MatcherFieldValue): Matcher {
};
}
export function matchersToString(matchers: Matcher[]) {
const matcherFields = matchers.map(matcherToMatcherField);
const combinedMatchers = matcherFields.reduce((acc, current) => {
const currentMatcherString = `${current.name}${current.operator}"${current.value}"`;
return acc ? `${acc},${currentMatcherString}` : currentMatcherString;
}, '');
return `{${combinedMatchers}}`;
}
export const matcherFieldOptions: SelectableValue[] = [
{ label: MatcherOperator.equal, description: 'Equals', value: MatcherOperator.equal },
{ label: MatcherOperator.notEqual, description: 'Does not equal', value: MatcherOperator.notEqual },

View File

@ -0,0 +1,33 @@
import Page from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState } from 'app/types';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useAsync } from 'react-use';
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
import { getFolderByUid } from './state/actions';
import { getLoadingNav } from './state/navModel';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
const FolderAlerting = ({ match }: OwnProps) => {
const dispatch = useDispatch();
const navIndex = useSelector((state: StoreState) => state.navIndex);
const folder = useSelector((state: StoreState) => state.folder);
const uid = match.params.uid;
const navModel = getNavModel(navIndex, `folder-alerting-${uid}`, getLoadingNav(1));
const { loading } = useAsync(async () => dispatch(getFolderByUid(uid)), [getFolderByUid, uid]);
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
<AlertsFolderView folder={folder} />
</Page.Contents>
</Page>
);
};
export default FolderAlerting;

View File

@ -1,14 +1,14 @@
import { locationUtil } from '@grafana/data';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { notifyApp, updateNavIndex } from 'app/core/actions';
import { createSuccessNotification, createWarningNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { backendSrv } from 'app/core/services/backend_srv';
import { FolderState, ThunkResult } from 'app/types';
import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl';
import { notifyApp, updateNavIndex } from 'app/core/actions';
import { createSuccessNotification, createWarningNotification } from 'app/core/copy/appNotification';
import { lastValueFrom } from 'rxjs';
import { buildNavModel } from './navModel';
import { loadFolder, loadFolderPermissions, setCanViewFolderPermissions } from './reducers';
import { lastValueFrom } from 'rxjs';
export function getFolderByUid(uid: string): ThunkResult<void> {
return async (dispatch) => {

View File

@ -29,6 +29,14 @@ export function buildNavModel(folder: FolderDTO): NavModelItem {
url: `${folder.url}/library-panels`,
});
model.children.push({
active: false,
icon: 'bell',
id: `folder-alerting-${folder.uid}`,
text: 'Alert rules',
url: `${folder.url}/alerting`,
});
if (folder.canAdmin) {
model.children.push({
active: false,

View File

@ -421,6 +421,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/folders/FolderLibraryPanelsPage')
),
},
{
path: '/dashboards/f/:uid/:slug/alerting',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/folders/FolderAlerting')
),
},
{
path: '/library-panels',
component: SafeDynamicImport(