import { css, cx } from '@emotion/css'; import { useEffect, useMemo } from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { Pagination, Tooltip, useStyles2 } from '@grafana/ui'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; import { alertRuleApi } from '../../api/alertRuleApi'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; import { useAsync } from '../../hooks/useAsync'; import { attachRulerRuleToCombinedRule } from '../../hooks/useCombinedRuleNamespaces'; import { useHasRuler } from '../../hooks/useHasRuler'; import { usePagination } from '../../hooks/usePagination'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { calculateNextEvaluationEstimate } from '../../rule-list/components/util'; import { Annotation } from '../../utils/constants'; import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource'; import { getRulePluginOrigin, isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; import { ProvisioningBadge } from '../Provisioning'; import { RuleLocation } from '../RuleLocation'; import { Tokenize } from '../Tokenize'; import { RuleActionsButtons } from './RuleActionsButtons'; import { RuleConfigStatus } from './RuleConfigStatus'; import { RuleDetails } from './RuleDetails'; import { RuleHealth } from './RuleHealth'; import { RuleState } from './RuleState'; type RuleTableColumnProps = DynamicTableColumnProps; type RuleTableItemProps = DynamicTableItemProps; interface Props { rules: CombinedRule[]; showGuidelines?: boolean; showGroupColumn?: boolean; showSummaryColumn?: boolean; showNextEvaluationColumn?: boolean; emptyMessage?: string; className?: string; } const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi; const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi; export const RulesTable = ({ rules, className, showGuidelines = false, emptyMessage = 'No rules found.', showGroupColumn = false, showSummaryColumn = false, showNextEvaluationColumn = false, }: Props) => { const styles = useStyles2(getStyles); const wrapperClass = cx(styles.wrapper, className, { [styles.wrapperMargin]: showGuidelines }); const { pageItems, page, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); const { result: rulesWithRulerDefinitions, status: rulerRulesLoadingStatus } = useLazyLoadRulerRules(pageItems); const isLoadingRulerGroup = rulerRulesLoadingStatus === 'loading'; const items = useMemo((): RuleTableItemProps[] => { return rulesWithRulerDefinitions.map((rule, ruleIdx) => { return { id: `${rule.namespace.name}-${rule.group.name}-${rule.name}-${ruleIdx}`, data: rule, }; }); }, [rulesWithRulerDefinitions]); const columns = useColumns(showSummaryColumn, showGroupColumn, showNextEvaluationColumn, isLoadingRulerGroup); if (!pageItems.length) { return
{emptyMessage}
; } const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable; return (
} />
); }; /** * This hook is used to lazy load the Ruler rule for each rule. * If the `prometheusRulesPrimary` feature flag is enabled, the hook will fetch the Ruler rule counterpart for each Prometheus rule. * If the `prometheusRulesPrimary` feature flag is disabled, the hook will return the rules as is. * @param rules Combined rules with or without Ruler rule property * @returns Combined rules enriched with Ruler rule property */ function useLazyLoadRulerRules(rules: CombinedRule[]) { const [fetchRulerRuleGroup] = useLazyGetRuleGroupForNamespaceQuery(); const [fetchDsFeatures] = useLazyDiscoverDsFeaturesQuery(); const [actions, state] = useAsync(async () => { const result = Promise.all( rules.map(async (rule) => { const dsFeatures = await fetchDsFeatures( { rulesSourceName: getRulesSourceName(rule.namespace.rulesSource) }, true ).unwrap(); // Due to lack of ruleUid and folderUid in Prometheus rules we cannot do the lazy load for GMA if (dsFeatures.rulerConfig && rule.namespace.rulesSource !== GRAFANA_RULES_SOURCE_NAME) { // RTK Query should handle caching and deduplication for us const rulerRuleGroup = await fetchRulerRuleGroup( { namespace: rule.namespace.name, group: rule.group.name, rulerConfig: dsFeatures.rulerConfig, }, true ).unwrap(); attachRulerRuleToCombinedRule(rule, rulerRuleGroup); } return rule; }) ); return result; }, rules); useEffect(() => { if (prometheusRulesPrimary) { actions.execute(); } else { // We need to reset the actions to update the rules if they changed // Otherwise useAsync acts like a cache and always return the first rules passed to it actions.reset(); } }, [rules, actions]); return state; } export const getStyles = (theme: GrafanaTheme2) => ({ wrapperMargin: css({ [theme.breakpoints.up('md')]: { marginLeft: '36px', }, }), emptyMessage: css({ padding: theme.spacing(1), }), wrapper: css({ width: 'auto', borderRadius: theme.shape.radius.default, }), skeletonWrapper: css({ flex: 1, }), pagination: css({ display: 'flex', margin: 0, paddingTop: theme.spacing(1), paddingBottom: theme.spacing(0.25), justifyContent: 'center', borderLeft: `1px solid ${theme.colors.border.medium}`, borderRight: `1px solid ${theme.colors.border.medium}`, borderBottom: `1px solid ${theme.colors.border.medium}`, float: 'none', }), }); function useColumns( showSummaryColumn: boolean, showGroupColumn: boolean, showNextEvaluationColumn: boolean, isRulerLoading: boolean ) { return useMemo((): RuleTableColumnProps[] => { const columns: RuleTableColumnProps[] = [ { id: 'state', label: 'State', renderCell: ({ data: rule }) => , size: '165px', }, { id: 'name', label: 'Name', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => rule.name, size: showNextEvaluationColumn ? 4 : 5, }, { id: 'metadata', label: '', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => { const { promRule, rulerRule } = rule; const originMeta = getRulePluginOrigin(promRule ?? rulerRule); if (originMeta) { return ; } const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule); if (!isGrafanaManagedRule) { return null; } const provenance = rulerRule.grafana_alert.provenance; return provenance ? : null; }, size: '100px', }, { id: 'warnings', label: '', renderCell: ({ data: combinedRule }) => , size: '45px', }, { id: 'health', label: 'Health', // eslint-disable-next-line react/display-name renderCell: ({ data: { promRule, group } }) => (promRule ? : null), size: '75px', }, ]; if (showSummaryColumn) { columns.push({ id: 'summary', label: 'Summary', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => { return ; }, size: showNextEvaluationColumn ? 4 : 5, }); } if (showNextEvaluationColumn) { columns.push({ id: 'nextEvaluation', label: 'Next evaluation', renderCell: ({ data: rule }) => { const nextEvalInfo = calculateNextEvaluationEstimate(rule.promRule?.lastEvaluation, rule.group.interval); return ( nextEvalInfo && ( {nextEvalInfo?.humanized} ) ); }, size: 2, }); } if (showGroupColumn) { columns.push({ id: 'group', label: 'Group', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => { const { namespace, group } = rule; // ungrouped rules are rules that are in the "default" group name const isUngrouped = group.name === 'default'; const groupName = isUngrouped ? ( ) : ( ); return groupName; }, size: 5, }); } columns.push({ id: 'actions', label: 'Actions', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => , size: '215px', }); return columns; }, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, isRulerLoading]); } function RuleStateCell({ rule }: { rule: CombinedRule }) { const { isDeleting, isCreating, isPaused } = useRuleStatus(rule); return ; } function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadingRuler: boolean }) { const styles = useStyles2(getStyles); const { isDeleting, isCreating } = useRuleStatus(rule); if (isLoadingRuler) { return ; } return ( ); } export function useIsRulesLoading(rulesSource: RulesSource) { const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules); const rulesSourceName = getRulesSourceName(rulesSource); const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result); return rulerRulesLoaded; } function useRuleStatus(rule: CombinedRule) { const rulesSource = rule.namespace.rulesSource; const rulerRulesLoaded = useIsRulesLoading(rulesSource); const { hasRuler } = useHasRuler(rulesSource); const { promRule, rulerRule } = rule; // If prometheusRulesPrimary is enabled, we don't fetch rules from the Ruler API (except for Grafana managed rules) // so there is no way to detect statuses if (prometheusRulesPrimary && !isGrafanaRulerRule(rulerRule)) { return { isDeleting: false, isCreating: false, isPaused: false }; } const isDeleting = Boolean(hasRuler && rulerRulesLoaded && promRule && !rulerRule); const isCreating = Boolean(hasRuler && rulerRulesLoaded && rulerRule && !promRule); const isPaused = isGrafanaRulerRule(rulerRule) && isGrafanaRulerRulePaused(rulerRule); return { isDeleting, isCreating, isPaused }; }