import { countBy, isEqual } from 'lodash'; import { useMemo, useRef } from 'react'; import { AlertGroupTotals, AlertInstanceTotalState, AlertInstanceTotals, AlertingRule, CombinedRule, CombinedRuleGroup, CombinedRuleNamespace, Rule, RuleGroup, RuleNamespace, RulesSource, } from 'app/types/unified-alerting'; import { PromAlertingRuleState, RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../api/alertRuleApi'; import { GRAFANA_RULER_CONFIG } from '../api/featureDiscoveryApi'; import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSources, getRulesSourceByName, isCloudRulesSource, isGrafanaRulesSource, } from '../utils/datasource'; import { hashQuery } from '../utils/rule-id'; import { isAlertingRule, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRule, isRecordingRulerRule, } from '../utils/rules'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export interface CacheValue { promRules?: RuleNamespace[]; rulerRules?: RulerRulesConfigDTO | null; result: CombinedRuleNamespace[]; } // this little monster combines prometheus rules and ruler rules to produce a unified data structure // can limit to a single rules source export function useCombinedRuleNamespaces( rulesSourceName?: string, grafanaPromRuleNamespaces?: RuleNamespace[] ): CombinedRuleNamespace[] { const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules); const rulerRulesResponses = useUnifiedAlertingSelector((state) => state.rulerRules); // cache results per rules source, so we only recalculate those for which results have actually changed const cache = useRef>({}); const rulesSources = useMemo((): RulesSource[] => { if (rulesSourceName) { const rulesSource = getRulesSourceByName(rulesSourceName); if (!rulesSource) { throw new Error(`Unknown rules source: ${rulesSourceName}`); } return [rulesSource]; } return getAllRulesSources(); }, [rulesSourceName]); return useMemo(() => { return rulesSources .map((rulesSource): CombinedRuleNamespace[] => { const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource; const rulerRules = rulerRulesResponses[rulesSourceName]?.result; let promRules = promRulesResponses[rulesSourceName]?.result; if (rulesSourceName === GRAFANA_RULES_SOURCE_NAME && grafanaPromRuleNamespaces) { promRules = grafanaPromRuleNamespaces; } const cached = cache.current[rulesSourceName]; if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) { return cached.result; } const namespaces: Record = {}; // first get all the ruler rules from the data source Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => { const namespace: CombinedRuleNamespace = { rulesSource, name: namespaceName, groups: [], }; // We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups // All rules from all groups have the same namespace_uid so we're taking the first one. if (isGrafanaRulerRule(groups[0].rules[0])) { namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid; } namespaces[namespaceName] = namespace; addRulerGroupsToCombinedNamespace(namespace, groups); }); // then correlate with prometheus rules promRules?.forEach(({ name: namespaceName, groups }) => { const ns = (namespaces[namespaceName] = namespaces[namespaceName] || { rulesSource, name: namespaceName, groups: [], }); addPromGroupsToCombinedNamespace(ns, groups); }); const result = Object.values(namespaces); cache.current[rulesSourceName] = { promRules, rulerRules, result }; return result; }) .flat(); }, [promRulesResponses, rulerRulesResponses, rulesSources, grafanaPromRuleNamespaces]); } export function combineRulesNamespace( rulesSource: RulesSource, promNamespaces: RuleNamespace[], rulerRules?: RulerRulesConfigDTO ): CombinedRuleNamespace[] { const namespaces: Record = {}; // first get all the ruler rules from the data source Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => { const namespace: CombinedRuleNamespace = { rulesSource, name: namespaceName, groups: [], }; namespaces[namespaceName] = namespace; addRulerGroupsToCombinedNamespace(namespace, groups); }); // then correlate with prometheus rules promNamespaces?.forEach(({ name: namespaceName, groups }) => { const ns = (namespaces[namespaceName] = namespaces[namespaceName] || { rulesSource, name: namespaceName, groups: [], }); addPromGroupsToCombinedNamespace(ns, groups); }); return Object.values(namespaces); } export function attachRulerRulesToCombinedRules( rulesSource: RulesSource, promNamespace: RuleNamespace, rulerGroups: RulerRuleGroupDTO[] ): CombinedRuleNamespace { const ns: CombinedRuleNamespace = { rulesSource: rulesSource, name: promNamespace.name, groups: [], }; // The order is important. Adding Ruler rules overrides Prometheus rules. addRulerGroupsToCombinedNamespace(ns, rulerGroups); addPromGroupsToCombinedNamespace(ns, promNamespace.groups); // Remove ruler rules which does not have Prom rule counterpart // This function should only attach Ruler rules to existing Prom rules ns.groups.forEach((group) => { group.rules = group.rules.filter((rule) => rule.promRule); }); return ns; } export function attachRulerRuleToCombinedRule(rule: CombinedRule, rulerGroup: RulerRuleGroupDTO): void { if (!rule.promRule) { return; } const combinedRulesFromRuler = rulerGroup.rules.map((rulerRule) => rulerRuleToCombinedRule(rulerRule, rule.namespace, rule.group) ); const existingRulerRulesByName = combinedRulesFromRuler.reduce((acc, rule) => { const sameNameRules = acc.get(rule.name); if (sameNameRules) { sameNameRules.push(rule); } else { acc.set(rule.name, [rule]); } return acc; }, new Map()); const matchingRulerRule = getExistingRuleInGroup(rule.promRule, existingRulerRulesByName, rule.namespace.rulesSource); if (matchingRulerRule) { rule.rulerRule = matchingRulerRule.rulerRule; rule.query = matchingRulerRule.query; rule.labels = matchingRulerRule.labels; rule.annotations = matchingRulerRule.annotations; } } export function addCombinedPromAndRulerGroups( ns: CombinedRuleNamespace, promGroups: RuleGroup[], rulerGroups: RulerRuleGroupDTO[] ): CombinedRuleNamespace { addRulerGroupsToCombinedNamespace(ns, rulerGroups); addPromGroupsToCombinedNamespace(ns, promGroups); return ns; } // merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) { return namespaces.map((namespace) => { const newNamespace: CombinedRuleNamespace = { ...namespace, groups: [], }; // add default group with ungrouped rules newNamespace.groups.push({ name: 'default', rules: sortRulesByName(namespace.groups.flatMap((group) => group.rules)), totals: calculateAllGroupsTotals(namespace.groups), }); return newNamespace; }); } export function sortRulesByName(rules: CombinedRule[]) { return rules.sort((a, b) => a.name.localeCompare(b.name)); } export function addRulerGroupsToCombinedNamespace( namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[] = [] ): void { namespace.groups = groups.map((group) => { const numRecordingRules = group.rules.filter((rule) => isRecordingRulerRule(rule)).length; const numPaused = group.rules.filter((rule) => isGrafanaRulerRule(rule) && rule.grafana_alert.is_paused).length; const combinedGroup: CombinedRuleGroup = { name: group.name, interval: group.interval, source_tenants: group.source_tenants, rules: [], totals: { paused: numPaused, recording: numRecordingRules, }, }; combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup)); return combinedGroup; }); } export function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RuleGroup[]): void { const existingGroupsByName = new Map(); namespace.groups.forEach((group) => existingGroupsByName.set(group.name, group)); groups.forEach((group) => { let combinedGroup = existingGroupsByName.get(group.name); if (!combinedGroup) { combinedGroup = { name: group.name, rules: [], totals: calculateGroupTotals(group), }; namespace.groups.push(combinedGroup); existingGroupsByName.set(group.name, combinedGroup); } // combine totals from ruler with totals from prometheus state API combinedGroup.totals = { ...combinedGroup.totals, ...calculateGroupTotals(group), }; const combinedRulesByName = new Map(); combinedGroup!.rules.forEach((r) => { // Prometheus rules do not have to be unique by name const existingRule = combinedRulesByName.get(r.name); existingRule ? existingRule.push(r) : combinedRulesByName.set(r.name, [r]); }); (group.rules ?? []).forEach((rule) => { const existingRule = getExistingRuleInGroup(rule, combinedRulesByName, namespace.rulesSource); if (existingRule) { existingRule.promRule = rule; existingRule.instanceTotals = isAlertingRule(rule) ? calculateRuleTotals(rule) : {}; existingRule.filteredInstanceTotals = isAlertingRule(rule) ? calculateRuleFilteredTotals(rule) : {}; } else { combinedGroup!.rules.push(promRuleToCombinedRule(rule, namespace, combinedGroup!)); } }); }); } export function calculateRuleTotals(rule: Pick): AlertInstanceTotals { const result = countBy(rule.alerts, 'state'); if (rule.totals) { const { normal, ...totals } = rule.totals; return { ...totals, inactive: normal }; } return { alerting: result[AlertInstanceTotalState.Alerting] || result.firing, pending: result[AlertInstanceTotalState.Pending], inactive: result[AlertInstanceTotalState.Normal], nodata: result[AlertInstanceTotalState.NoData], error: result[AlertInstanceTotalState.Error] || result.err || undefined, // Prometheus uses "err" instead of "error" }; } export function calculateRuleFilteredTotals( rule: Pick ): AlertInstanceTotals { if (rule.totalsFiltered) { const { normal, ...totals } = rule.totalsFiltered; return { ...totals, inactive: normal }; } return {}; } export function calculateGroupTotals(group: Pick): AlertGroupTotals { if (group.totals) { const { firing, ...totals } = group.totals; return { ...totals, alerting: firing, }; } const countsByState = countBy(group.rules, (rule) => isAlertingRule(rule) && rule.state); const countsByHealth = countBy(group.rules, (rule) => rule.health); const recordingCount = group.rules.filter((rule) => isRecordingRule(rule)).length; return { alerting: countsByState[PromAlertingRuleState.Firing], error: countsByHealth.error, nodata: countsByHealth.nodata, inactive: countsByState[PromAlertingRuleState.Inactive], pending: countsByState[PromAlertingRuleState.Pending], recording: recordingCount, }; } function calculateAllGroupsTotals(groups: CombinedRuleGroup[]): AlertGroupTotals { const totals: Record = {}; groups.forEach((group) => { const groupTotals = group.totals; Object.entries(groupTotals).forEach(([key, value]) => { if (!totals[key]) { totals[key] = 0; } if (value !== undefined && value !== null) { totals[key] += value; } }); }); return totals; } function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, group: CombinedRuleGroup): CombinedRule { return { name: rule.name, query: rule.query, labels: rule.labels || {}, annotations: isAlertingRule(rule) ? rule.annotations || {} : {}, promRule: rule, namespace: namespace, group, instanceTotals: isAlertingRule(rule) ? calculateRuleTotals(rule) : {}, filteredInstanceTotals: isAlertingRule(rule) ? calculateRuleFilteredTotals(rule) : {}, }; } function rulerRuleToCombinedRule( rule: RulerRuleDTO, namespace: CombinedRuleNamespace, group: CombinedRuleGroup ): CombinedRule { return isAlertingRulerRule(rule) ? { name: rule.alert, query: rule.expr, labels: rule.labels || {}, annotations: rule.annotations || {}, rulerRule: rule, namespace, group, instanceTotals: {}, filteredInstanceTotals: {}, } : isRecordingRulerRule(rule) ? { name: rule.record, query: rule.expr, labels: rule.labels || {}, annotations: {}, rulerRule: rule, namespace, group, instanceTotals: {}, filteredInstanceTotals: {}, } : { name: rule.grafana_alert.title, query: '', labels: rule.labels || {}, annotations: rule.annotations || {}, rulerRule: rule, namespace, group, instanceTotals: {}, filteredInstanceTotals: {}, }; } // find existing rule in group that matches the given prom rule function getExistingRuleInGroup( rule: Rule, existingCombinedRulesMap: Map, rulesSource: RulesSource ): CombinedRule | undefined { // Using Map of name-based rules is important performance optimization for the code below // Otherwise we would perform find method multiple times on (possibly) thousands of rules const nameMatchingRules = existingCombinedRulesMap.get(rule.name); if (!nameMatchingRules) { return undefined; } if (isGrafanaRulesSource(rulesSource)) { // assume grafana groups have only the one rule. check name anyway because paranoid return nameMatchingRules[0]; } // try finding a rule that matches name, labels, annotations and query const strictlyMatchingRule = nameMatchingRules.find( (combinedRule) => !combinedRule.promRule && isCombinedRuleEqualToPromRule(combinedRule, rule, true) ); if (strictlyMatchingRule) { return strictlyMatchingRule; } // if that fails, try finding a rule that only matches name, labels and annotations. // loki & prom can sometimes modify the query so it doesnt match, eg `2 > 1` becomes `1` const looselyMatchingRule = nameMatchingRules.find( (combinedRule) => !combinedRule.promRule && isCombinedRuleEqualToPromRule(combinedRule, rule, false) ); if (looselyMatchingRule) { return looselyMatchingRule; } return undefined; } function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule, checkQuery = true): boolean { if (combinedRule.name === rule.name) { return isEqual( [checkQuery ? hashQuery(combinedRule.query) : '', combinedRule.labels, combinedRule.annotations], [checkQuery ? hashQuery(rule.query) : '', rule.labels || {}, isAlertingRule(rule) ? rule.annotations || {} : {}] ); } return false; } /* This hook returns combined Grafana rules. Optionally, it can filter rules by dashboard UID and panel ID. */ export function useCombinedRules( dashboardUID?: string | null, panelId?: number, poll?: boolean ): { loading: boolean; result?: CombinedRuleNamespace[]; error?: unknown; } { const isNewDashboard = !Boolean(dashboardUID); const { currentData: promRuleNs, isLoading: isLoadingPromRules, error: promRuleNsError, } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( { ruleSourceName: GRAFANA_RULES_SOURCE_NAME, dashboardUid: dashboardUID ?? undefined, panelId, }, { skip: isNewDashboard, pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, } ); const { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError, } = alertRuleApi.endpoints.rulerRules.useQuery( { rulerConfig: GRAFANA_RULER_CONFIG, filter: { dashboardUID: dashboardUID ?? undefined, panelId }, }, { pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, skip: isNewDashboard, } ); //--------- // cache results per rules source, so we only recalculate those for which results have actually changed const cache = useRef>({}); const rulesSource = getRulesSourceByName(GRAFANA_RULES_SOURCE_NAME); const rules = useMemo(() => { if (!rulesSource) { return []; } const cached = cache.current[GRAFANA_RULES_SOURCE_NAME]; if (cached && cached.promRules === promRuleNs && cached.rulerRules === rulerRules) { return cached.result; } const namespaces: Record = {}; // first get all the ruler rules from the data source Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => { const namespace: CombinedRuleNamespace = { rulesSource, name: namespaceName, groups: [], }; // We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups // All rules from all groups have the same namespace_uid so we're taking the first one. if (isGrafanaRulerRule(groups[0].rules[0])) { namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid; } namespaces[namespaceName] = namespace; addRulerGroupsToCombinedNamespace(namespace, groups); }); // then correlate with prometheus rules promRuleNs?.forEach(({ name: namespaceName, groups }) => { const ns = (namespaces[namespaceName] = namespaces[namespaceName] || { rulesSource, name: namespaceName, groups: [], }); addPromGroupsToCombinedNamespace(ns, groups); }); const result = Object.values(namespaces); cache.current[GRAFANA_RULES_SOURCE_NAME] = { promRules: promRuleNs, rulerRules, result }; return result; }, [promRuleNs, rulerRules, rulesSource]); return { loading: isLoadingPromRules || isLoadingRulerRules, error: promRuleNsError ?? rulerRulesError, result: rules, }; }