diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts index 64ee85701f3..166a98a1857 100644 --- a/public/app/core/utils/query.ts +++ b/public/app/core/utils/query.ts @@ -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; +} diff --git a/public/app/features/alerting/unified/AlertsFolderView.test.tsx b/public/app/features/alerting/unified/AlertsFolderView.test.tsx new file mode 100644 index 00000000000..940bd63d141 --- /dev/null +++ b/public/app/features/alerting/unified/AlertsFolderView.test.tsx @@ -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(); +jest.mock('./hooks/useCombinedRuleNamespaces', () => ({ + useCombinedRuleNamespaces: () => combinedNamespaceMock(), +})); + +const mockFolder = (folderOverride: Partial = {}): 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( + + + + + + ); + + // 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( + + + + + + ); + + // 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( + + + + + + ); + + 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( + + + + + + ); + + 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'); + }); +}); diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx new file mode 100644 index 00000000000..1109dc3c9c6 --- /dev/null +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -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> = [ + { 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 ( +
+ + + + + value={sortOrder} + onChange={({ value }) => value && setSortOrder(value)} + options={sortOptions} + width={25} + aria-label="Sort" + placeholder={`Sort (Default A-Z)`} + prefix={} + /> + + + + + {pageItems.map((currentRule) => ( + + {currentRule.name} + + `${label}=${value}`)} + /> + + +
+ {folder.title} +
+
+
+ ))} +
+ {hasNoResults &&
No alert rules found
} +
+ +
+
+
+ ); +}; + +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( + 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; + `, +}); diff --git a/public/app/features/alerting/unified/hooks/usePagination.ts b/public/app/features/alerting/unified/hooks/usePagination.ts new file mode 100644 index 00000000000..f5fc76fe15e --- /dev/null +++ b/public/app/features/alerting/unified/hooks/usePagination.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +export function usePagination(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 }; +} diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index c6372994465..b45212275a7 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -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"}'); + }); + }); }); diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index cfa9a963f44..4e93b0824a4 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -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 }, diff --git a/public/app/features/folders/FolderAlerting.tsx b/public/app/features/folders/FolderAlerting.tsx new file mode 100644 index 00000000000..c1ea801a3ec --- /dev/null +++ b/public/app/features/folders/FolderAlerting.tsx @@ -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 ( + + + + + + ); +}; + +export default FolderAlerting; diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index 51caf2e98c1..4d6fa6548a5 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -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 { return async (dispatch) => { diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 1ca2fad5f90..628c5e0089e 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -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, diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 938cafd5f8e..967dac17f75 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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(