SilencesTable.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import { css } from '@emotion/css';
  2. import React, { FC, useMemo } from 'react';
  3. import { useDispatch } from 'react-redux';
  4. import { GrafanaTheme2, dateMath } from '@grafana/data';
  5. import { Stack } from '@grafana/experimental';
  6. import { Icon, useStyles2, Link, Button } from '@grafana/ui';
  7. import { useQueryParams } from 'app/core/hooks/useQueryParams';
  8. import { contextSrv } from 'app/core/services/context_srv';
  9. import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
  10. import { expireSilenceAction } from '../../state/actions';
  11. import { getInstancesPermissions } from '../../utils/access-control';
  12. import { parseMatchers } from '../../utils/alertmanager';
  13. import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
  14. import { Authorize } from '../Authorize';
  15. import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
  16. import { ActionButton } from '../rules/ActionButton';
  17. import { ActionIcon } from '../rules/ActionIcon';
  18. import { Matchers } from './Matchers';
  19. import { NoSilencesSplash } from './NoSilencesCTA';
  20. import { SilenceDetails } from './SilenceDetails';
  21. import { SilenceStateTag } from './SilenceStateTag';
  22. import { SilencesFilter } from './SilencesFilter';
  23. export interface SilenceTableItem extends Silence {
  24. silencedAlerts: AlertmanagerAlert[];
  25. }
  26. type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
  27. type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
  28. interface Props {
  29. silences: Silence[];
  30. alertManagerAlerts: AlertmanagerAlert[];
  31. alertManagerSourceName: string;
  32. }
  33. const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
  34. const styles = useStyles2(getStyles);
  35. const [queryParams] = useQueryParams();
  36. const filteredSilences = useFilteredSilences(silences);
  37. const permissions = getInstancesPermissions(alertManagerSourceName);
  38. const { silenceState } = getSilenceFiltersFromUrlParams(queryParams);
  39. const showExpiredSilencesBanner =
  40. !!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
  41. const columns = useColumns(alertManagerSourceName);
  42. const items = useMemo((): SilenceTableItemProps[] => {
  43. const findSilencedAlerts = (id: string) => {
  44. return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
  45. };
  46. return filteredSilences.map((silence) => {
  47. const silencedAlerts = findSilencedAlerts(silence.id);
  48. return {
  49. id: silence.id,
  50. data: { ...silence, silencedAlerts },
  51. };
  52. });
  53. }, [filteredSilences, alertManagerAlerts]);
  54. return (
  55. <div data-testid="silences-table">
  56. {!!silences.length && (
  57. <>
  58. <SilencesFilter />
  59. <Authorize actions={[permissions.create]} fallback={contextSrv.isEditor}>
  60. <div className={styles.topButtonContainer}>
  61. <Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
  62. <Button className={styles.addNewSilence} icon="plus">
  63. New Silence
  64. </Button>
  65. </Link>
  66. </div>
  67. </Authorize>
  68. {!!items.length ? (
  69. <>
  70. <DynamicTable
  71. items={items}
  72. cols={columns}
  73. isExpandable
  74. renderExpandedContent={({ data }) => <SilenceDetails silence={data} />}
  75. />
  76. {showExpiredSilencesBanner && (
  77. <div className={styles.callout}>
  78. <Icon className={styles.calloutIcon} name="info-circle" />
  79. <span>Expired silences are automatically deleted after 5 days.</span>
  80. </div>
  81. )}
  82. </>
  83. ) : (
  84. 'No matching silences found'
  85. )}
  86. </>
  87. )}
  88. {!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
  89. </div>
  90. );
  91. };
  92. const useFilteredSilences = (silences: Silence[]) => {
  93. const [queryParams] = useQueryParams();
  94. return useMemo(() => {
  95. const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams);
  96. const silenceIdsString = queryParams?.silenceIds;
  97. return silences.filter((silence) => {
  98. if (typeof silenceIdsString === 'string') {
  99. const idsIncluded = silenceIdsString.split(',').includes(silence.id);
  100. if (!idsIncluded) {
  101. return false;
  102. }
  103. }
  104. if (queryString) {
  105. const matchers = parseMatchers(queryString);
  106. const matchersMatch = matchers.every((matcher) =>
  107. silence.matchers?.some(
  108. ({ name, value, isEqual, isRegex }) =>
  109. matcher.name === name &&
  110. matcher.value === value &&
  111. matcher.isEqual === isEqual &&
  112. matcher.isRegex === isRegex
  113. )
  114. );
  115. if (!matchersMatch) {
  116. return false;
  117. }
  118. }
  119. if (silenceState) {
  120. const stateMatches = silence.status.state === silenceState;
  121. if (!stateMatches) {
  122. return false;
  123. }
  124. }
  125. return true;
  126. });
  127. }, [queryParams, silences]);
  128. };
  129. const getStyles = (theme: GrafanaTheme2) => ({
  130. topButtonContainer: css`
  131. display: flex;
  132. flex-direction: row;
  133. justify-content: flex-end;
  134. `,
  135. addNewSilence: css`
  136. margin: ${theme.spacing(2, 0)};
  137. `,
  138. callout: css`
  139. background-color: ${theme.colors.background.secondary};
  140. border-top: 3px solid ${theme.colors.info.border};
  141. border-radius: 2px;
  142. height: 62px;
  143. display: flex;
  144. flex-direction: row;
  145. align-items: center;
  146. margin-top: ${theme.spacing(2)};
  147. & > * {
  148. margin-left: ${theme.spacing(1)};
  149. }
  150. `,
  151. calloutIcon: css`
  152. color: ${theme.colors.info.text};
  153. `,
  154. editButton: css`
  155. margin-left: ${theme.spacing(0.5)};
  156. `,
  157. });
  158. function useColumns(alertManagerSourceName: string) {
  159. const dispatch = useDispatch();
  160. const styles = useStyles2(getStyles);
  161. const permissions = getInstancesPermissions(alertManagerSourceName);
  162. return useMemo((): SilenceTableColumnProps[] => {
  163. const handleExpireSilenceClick = (id: string) => {
  164. dispatch(expireSilenceAction(alertManagerSourceName, id));
  165. };
  166. const showActions = contextSrv.hasAccess(permissions.update, contextSrv.isEditor);
  167. const columns: SilenceTableColumnProps[] = [
  168. {
  169. id: 'state',
  170. label: 'State',
  171. renderCell: function renderStateTag({ data: { status } }) {
  172. return <SilenceStateTag state={status.state} />;
  173. },
  174. size: '88px',
  175. },
  176. {
  177. id: 'matchers',
  178. label: 'Matching labels',
  179. renderCell: function renderMatchers({ data: { matchers } }) {
  180. return <Matchers matchers={matchers || []} />;
  181. },
  182. size: 9,
  183. },
  184. {
  185. id: 'alerts',
  186. label: 'Alerts',
  187. renderCell: function renderSilencedAlerts({ data: { silencedAlerts } }) {
  188. return <span data-testid="alerts">{silencedAlerts.length}</span>;
  189. },
  190. size: 1,
  191. },
  192. {
  193. id: 'schedule',
  194. label: 'Schedule',
  195. renderCell: function renderSchedule({ data: { startsAt, endsAt } }) {
  196. const startsAtDate = dateMath.parse(startsAt);
  197. const endsAtDate = dateMath.parse(endsAt);
  198. const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
  199. return (
  200. <>
  201. {' '}
  202. {startsAtDate?.format(dateDisplayFormat)} {'-'}
  203. <br />
  204. {endsAtDate?.format(dateDisplayFormat)}
  205. </>
  206. );
  207. },
  208. size: '150px',
  209. },
  210. ];
  211. if (showActions) {
  212. columns.push({
  213. id: 'actions',
  214. label: 'Actions',
  215. renderCell: function renderActions({ data: silence }) {
  216. return (
  217. <Stack gap={0.5}>
  218. {silence.status.state === 'expired' ? (
  219. <Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
  220. <ActionButton icon="sync">Recreate</ActionButton>
  221. </Link>
  222. ) : (
  223. <ActionButton icon="bell" onClick={() => handleExpireSilenceClick(silence.id)}>
  224. Unsilence
  225. </ActionButton>
  226. )}
  227. {silence.status.state !== 'expired' && (
  228. <ActionIcon
  229. className={styles.editButton}
  230. to={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
  231. icon="pen"
  232. tooltip="edit"
  233. />
  234. )}
  235. </Stack>
  236. );
  237. },
  238. size: '147px',
  239. });
  240. }
  241. return columns;
  242. }, [alertManagerSourceName, dispatch, styles, permissions]);
  243. }
  244. export default SilencesTable;