useCombinedRuleNamespaces.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { useMemo, useRef } from 'react';
  2. import {
  3. CombinedRule,
  4. CombinedRuleGroup,
  5. CombinedRuleNamespace,
  6. Rule,
  7. RuleGroup,
  8. RuleNamespace,
  9. RulesSource,
  10. } from 'app/types/unified-alerting';
  11. import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
  12. import {
  13. getAllRulesSources,
  14. getRulesSourceByName,
  15. isCloudRulesSource,
  16. isGrafanaRulesSource,
  17. } from '../utils/datasource';
  18. import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules';
  19. import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
  20. interface CacheValue {
  21. promRules?: RuleNamespace[];
  22. rulerRules?: RulerRulesConfigDTO | null;
  23. result: CombinedRuleNamespace[];
  24. }
  25. // this little monster combines prometheus rules and ruler rules to produce a unified data structure
  26. // can limit to a single rules source
  27. export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRuleNamespace[] {
  28. const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules);
  29. const rulerRulesResponses = useUnifiedAlertingSelector((state) => state.rulerRules);
  30. // cache results per rules source, so we only recalculate those for which results have actually changed
  31. const cache = useRef<Record<string, CacheValue>>({});
  32. const rulesSources = useMemo((): RulesSource[] => {
  33. if (rulesSourceName) {
  34. const rulesSource = getRulesSourceByName(rulesSourceName);
  35. if (!rulesSource) {
  36. throw new Error(`Unknown rules source: ${rulesSourceName}`);
  37. }
  38. return [rulesSource];
  39. }
  40. return getAllRulesSources();
  41. }, [rulesSourceName]);
  42. return useMemo(
  43. () =>
  44. rulesSources
  45. .map((rulesSource): CombinedRuleNamespace[] => {
  46. const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
  47. const promRules = promRulesResponses[rulesSourceName]?.result;
  48. const rulerRules = rulerRulesResponses[rulesSourceName]?.result;
  49. const cached = cache.current[rulesSourceName];
  50. if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) {
  51. return cached.result;
  52. }
  53. const namespaces: Record<string, CombinedRuleNamespace> = {};
  54. // first get all the ruler rules in
  55. Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
  56. const namespace: CombinedRuleNamespace = {
  57. rulesSource,
  58. name: namespaceName,
  59. groups: [],
  60. };
  61. namespaces[namespaceName] = namespace;
  62. addRulerGroupsToCombinedNamespace(namespace, groups);
  63. });
  64. // then correlate with prometheus rules
  65. promRules?.forEach(({ name: namespaceName, groups }) => {
  66. const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
  67. rulesSource,
  68. name: namespaceName,
  69. groups: [],
  70. });
  71. addPromGroupsToCombinedNamespace(ns, groups);
  72. });
  73. const result = Object.values(namespaces);
  74. cache.current[rulesSourceName] = { promRules, rulerRules, result };
  75. return result;
  76. })
  77. .flat(),
  78. [promRulesResponses, rulerRulesResponses, rulesSources]
  79. );
  80. }
  81. // merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups
  82. export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) {
  83. return namespaces.map((namespace) => {
  84. const newNamespace: CombinedRuleNamespace = {
  85. ...namespace,
  86. groups: [],
  87. };
  88. // add default group with ungrouped rules
  89. newNamespace.groups.push({
  90. name: 'default',
  91. rules: sortRulesByName(namespace.groups.flatMap((group) => group.rules)),
  92. });
  93. return newNamespace;
  94. });
  95. }
  96. export function sortRulesByName(rules: CombinedRule[]) {
  97. return rules.sort((a, b) => a.name.localeCompare(b.name));
  98. }
  99. function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[]): void {
  100. namespace.groups = groups.map((group) => {
  101. const combinedGroup: CombinedRuleGroup = {
  102. name: group.name,
  103. interval: group.interval,
  104. source_tenants: group.source_tenants,
  105. rules: [],
  106. };
  107. combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup));
  108. return combinedGroup;
  109. });
  110. }
  111. function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RuleGroup[]): void {
  112. groups.forEach((group) => {
  113. let combinedGroup = namespace.groups.find((g) => g.name === group.name);
  114. if (!combinedGroup) {
  115. combinedGroup = {
  116. name: group.name,
  117. rules: [],
  118. };
  119. namespace.groups.push(combinedGroup);
  120. }
  121. (group.rules ?? []).forEach((rule) => {
  122. const existingRule = getExistingRuleInGroup(rule, combinedGroup!, namespace.rulesSource);
  123. if (existingRule) {
  124. existingRule.promRule = rule;
  125. } else {
  126. combinedGroup!.rules.push(promRuleToCombinedRule(rule, namespace, combinedGroup!));
  127. }
  128. });
  129. });
  130. }
  131. function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, group: CombinedRuleGroup): CombinedRule {
  132. return {
  133. name: rule.name,
  134. query: rule.query,
  135. labels: rule.labels || {},
  136. annotations: isAlertingRule(rule) ? rule.annotations || {} : {},
  137. promRule: rule,
  138. namespace: namespace,
  139. group,
  140. };
  141. }
  142. function rulerRuleToCombinedRule(
  143. rule: RulerRuleDTO,
  144. namespace: CombinedRuleNamespace,
  145. group: CombinedRuleGroup
  146. ): CombinedRule {
  147. return isAlertingRulerRule(rule)
  148. ? {
  149. name: rule.alert,
  150. query: rule.expr,
  151. labels: rule.labels || {},
  152. annotations: rule.annotations || {},
  153. rulerRule: rule,
  154. namespace,
  155. group,
  156. }
  157. : isRecordingRulerRule(rule)
  158. ? {
  159. name: rule.record,
  160. query: rule.expr,
  161. labels: rule.labels || {},
  162. annotations: {},
  163. rulerRule: rule,
  164. namespace,
  165. group,
  166. }
  167. : {
  168. name: rule.grafana_alert.title,
  169. query: '',
  170. labels: rule.labels || {},
  171. annotations: rule.annotations || {},
  172. rulerRule: rule,
  173. namespace,
  174. group,
  175. };
  176. }
  177. // find existing rule in group that matches the given prom rule
  178. function getExistingRuleInGroup(
  179. rule: Rule,
  180. group: CombinedRuleGroup,
  181. rulesSource: RulesSource
  182. ): CombinedRule | undefined {
  183. if (isGrafanaRulesSource(rulesSource)) {
  184. // assume grafana groups have only the one rule. check name anyway because paranoid
  185. return group!.rules.find((existingRule) => existingRule.name === rule.name);
  186. }
  187. return (
  188. // try finding a rule that matches name, labels, annotations and query
  189. group!.rules.find(
  190. (existingRule) => !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule, true)
  191. ) ??
  192. // if that fails, try finding a rule that only matches name, labels and annotations.
  193. // loki & prom can sometimes modify the query so it doesnt match, eg `2 > 1` becomes `1`
  194. group!.rules.find(
  195. (existingRule) => !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule, false)
  196. )
  197. );
  198. }
  199. function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule, checkQuery = true): boolean {
  200. if (combinedRule.name === rule.name) {
  201. return (
  202. JSON.stringify([
  203. checkQuery ? hashQuery(combinedRule.query) : '',
  204. combinedRule.labels,
  205. combinedRule.annotations,
  206. ]) ===
  207. JSON.stringify([
  208. checkQuery ? hashQuery(rule.query) : '',
  209. rule.labels || {},
  210. isAlertingRule(rule) ? rule.annotations || {} : {},
  211. ])
  212. );
  213. }
  214. return false;
  215. }
  216. // there can be slight differences in how prom & ruler render a query, this will hash them accounting for the differences
  217. function hashQuery(query: string) {
  218. // one of them might be wrapped in parens
  219. if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') {
  220. query = query.slice(1, -1);
  221. }
  222. // whitespace could be added or removed
  223. query = query.replace(/\s|\n/g, '');
  224. // labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts
  225. return query.split('').sort().join('');
  226. }