AlertsFolderView.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { css } from '@emotion/css';
  2. import { isEqual, orderBy, uniqWith } from 'lodash';
  3. import React, { useEffect, useState } from 'react';
  4. import { useDispatch } from 'react-redux';
  5. import { useDebounce } from 'react-use';
  6. import { GrafanaTheme2, SelectableValue } from '@grafana/data';
  7. import { Stack } from '@grafana/experimental';
  8. import { Card, FilterInput, Icon, Pagination, Select, TagList, useStyles2 } from '@grafana/ui';
  9. import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
  10. import { getQueryParamValue } from 'app/core/utils/query';
  11. import { FolderState } from 'app/types';
  12. import { CombinedRule } from 'app/types/unified-alerting';
  13. import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
  14. import { usePagination } from './hooks/usePagination';
  15. import { useURLSearchParams } from './hooks/useURLSearchParams';
  16. import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions';
  17. import { labelsMatchMatchers, matchersToString, parseMatcher, parseMatchers } from './utils/alertmanager';
  18. import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
  19. import { createViewLink } from './utils/misc';
  20. interface Props {
  21. folder: FolderState;
  22. }
  23. enum SortOrder {
  24. Ascending = 'alpha-asc',
  25. Descending = 'alpha-desc',
  26. }
  27. const sortOptions: Array<SelectableValue<SortOrder>> = [
  28. { label: 'Alphabetically [A-Z]', value: SortOrder.Ascending },
  29. { label: 'Alphabetically [Z-A]', value: SortOrder.Descending },
  30. ];
  31. export const AlertsFolderView = ({ folder }: Props) => {
  32. const styles = useStyles2(getStyles);
  33. const dispatch = useDispatch();
  34. const onTagClick = (tagName: string) => {
  35. const matchers = parseMatchers(labelFilter);
  36. const tagMatcherField = parseMatcher(tagName);
  37. const uniqueMatchers = uniqWith([...matchers, tagMatcherField], isEqual);
  38. const matchersString = matchersToString(uniqueMatchers);
  39. setLabelFilter(matchersString);
  40. };
  41. useEffect(() => {
  42. dispatch(fetchPromRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
  43. dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
  44. }, [dispatch]);
  45. const combinedNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
  46. const { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder } =
  47. useAlertsFolderViewParams();
  48. const matchingNamespace = combinedNamespaces.find((namespace) => namespace.name === folder.title);
  49. const alertRules = matchingNamespace?.groups.flatMap((group) => group.rules) ?? [];
  50. const filteredRules = filterAndSortRules(alertRules, nameFilter, labelFilter, sortOrder ?? SortOrder.Ascending);
  51. const hasNoResults = alertRules.length === 0 || filteredRules.length === 0;
  52. const { page, numberOfPages, onPageChange, pageItems } = usePagination(filteredRules, 1, DEFAULT_PER_PAGE_PAGINATION);
  53. return (
  54. <div className={styles.container}>
  55. <Stack direction="column" gap={3}>
  56. <FilterInput
  57. value={nameFilter}
  58. onChange={setNameFilter}
  59. placeholder="Search alert rules by name"
  60. data-testid="name-filter"
  61. />
  62. <Stack direction="row">
  63. <Select<SortOrder>
  64. value={sortOrder}
  65. onChange={({ value }) => value && setSortOrder(value)}
  66. options={sortOptions}
  67. width={25}
  68. aria-label="Sort"
  69. placeholder={`Sort (Default A-Z)`}
  70. prefix={<Icon name={sortOrder === SortOrder.Ascending ? 'sort-amount-up' : 'sort-amount-down'} />}
  71. />
  72. <FilterInput
  73. value={labelFilter}
  74. onChange={setLabelFilter}
  75. placeholder="Search alerts by labels"
  76. className={styles.filterLabelsInput}
  77. data-testid="label-filter"
  78. />
  79. </Stack>
  80. <Stack gap={1}>
  81. {pageItems.map((currentRule) => (
  82. <Card
  83. key={currentRule.name}
  84. href={createViewLink('grafana', currentRule, '')}
  85. className={styles.card}
  86. data-testid="alert-card-row"
  87. >
  88. <Card.Heading>{currentRule.name}</Card.Heading>
  89. <Card.Tags>
  90. <TagList
  91. onClick={onTagClick}
  92. tags={Object.entries(currentRule.labels).map(([label, value]) => `${label}=${value}`)}
  93. />
  94. </Card.Tags>
  95. <Card.Meta>
  96. <div>
  97. <Icon name="folder" /> {folder.title}
  98. </div>
  99. </Card.Meta>
  100. </Card>
  101. ))}
  102. </Stack>
  103. {hasNoResults && <div className={styles.noResults}>No alert rules found</div>}
  104. <div className={styles.pagination}>
  105. <Pagination
  106. currentPage={page}
  107. numberOfPages={numberOfPages}
  108. onNavigate={onPageChange}
  109. hideWhenSinglePage={true}
  110. />
  111. </div>
  112. </Stack>
  113. </div>
  114. );
  115. };
  116. enum AlertFolderViewParams {
  117. nameFilter = 'nameFilter',
  118. labelFilter = 'labelFilter',
  119. sortOrder = 'sort',
  120. }
  121. function useAlertsFolderViewParams() {
  122. const [searchParams, setSearchParams] = useURLSearchParams();
  123. const [nameFilter, setNameFilter] = useState(searchParams.get(AlertFolderViewParams.nameFilter) ?? '');
  124. const [labelFilter, setLabelFilter] = useState(searchParams.get(AlertFolderViewParams.labelFilter) ?? '');
  125. const sortParam = searchParams.get(AlertFolderViewParams.sortOrder);
  126. const [sortOrder, setSortOrder] = useState<SortOrder | undefined>(
  127. sortParam === SortOrder.Ascending
  128. ? SortOrder.Ascending
  129. : sortParam === SortOrder.Descending
  130. ? SortOrder.Descending
  131. : undefined
  132. );
  133. useDebounce(
  134. () =>
  135. setSearchParams(
  136. {
  137. [AlertFolderViewParams.nameFilter]: getQueryParamValue(nameFilter),
  138. [AlertFolderViewParams.labelFilter]: getQueryParamValue(labelFilter),
  139. [AlertFolderViewParams.sortOrder]: getQueryParamValue(sortOrder),
  140. },
  141. true
  142. ),
  143. 400,
  144. [nameFilter, labelFilter, sortOrder]
  145. );
  146. return { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder };
  147. }
  148. function filterAndSortRules(
  149. originalRules: CombinedRule[],
  150. nameFilter: string,
  151. labelFilter: string,
  152. sortOrder: SortOrder
  153. ) {
  154. const matchers = parseMatchers(labelFilter);
  155. let rules = originalRules.filter(
  156. (rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers)
  157. );
  158. return orderBy(rules, (x) => x.name.toLowerCase(), [sortOrder === SortOrder.Ascending ? 'asc' : 'desc']);
  159. }
  160. export const getStyles = (theme: GrafanaTheme2) => ({
  161. container: css`
  162. padding: ${theme.spacing(1)};
  163. `,
  164. card: css`
  165. grid-template-columns: auto 1fr 2fr;
  166. margin: 0;
  167. `,
  168. pagination: css`
  169. align-self: center;
  170. `,
  171. filterLabelsInput: css`
  172. flex: 1;
  173. width: auto;
  174. min-width: 240px;
  175. `,
  176. noResults: css`
  177. padding: ${theme.spacing(2)};
  178. background-color: ${theme.colors.background.secondary};
  179. font-style: italic;
  180. `,
  181. });