grafana/public/app/features/alerting/unified/components/rules/RulesTable.tsx

365 lines
12 KiB
TypeScript

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, isPausedRule, rulerRuleType } 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<CombinedRule>;
type RuleTableItemProps = DynamicTableItemProps<CombinedRule>;
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 <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
}
const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable;
return (
<div className={wrapperClass} data-testid="rules-table">
<TableComponent
cols={columns}
isExpandable={true}
items={items}
renderExpandedContent={({ data: rule }) => <RuleDetails rule={rule} />}
/>
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
className={styles.pagination}
/>
</div>
);
};
/**
* 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 }) => <RuleStateCell rule={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 <PluginOriginBadge pluginId={originMeta.pluginId} />;
}
const isGrafanaManagedRule = rulerRuleType.grafana.rule(rulerRule);
if (!isGrafanaManagedRule) {
return null;
}
const provenance = rulerRule.grafana_alert.provenance;
return provenance ? <ProvisioningBadge /> : null;
},
size: '100px',
},
{
id: 'warnings',
label: '',
renderCell: ({ data: combinedRule }) => <RuleConfigStatus rule={combinedRule} />,
size: '45px',
},
{
id: 'health',
label: 'Health',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { promRule, group } }) => (promRule ? <RuleHealth rule={promRule} /> : null),
size: '75px',
},
];
if (showSummaryColumn) {
columns.push({
id: 'summary',
label: 'Summary',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => {
return <Tokenize input={rule.annotations[Annotation.summary] ?? ''} />;
},
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 && (
<Tooltip
placement="top"
// eslint-disable-next-line @grafana/no-untranslated-strings
content={`${nextEvalInfo?.fullDate}`}
theme="info"
>
<span>{nextEvalInfo?.humanized}</span>
</Tooltip>
)
);
},
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 ? (
<RuleLocation namespace={namespace.name} />
) : (
<RuleLocation namespace={namespace.name} group={group.name} />
);
return groupName;
},
size: 5,
});
}
columns.push({
id: 'actions',
label: 'Actions',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => <RuleActionsCell rule={rule} isLoadingRuler={isRulerLoading} />,
size: '215px',
});
return columns;
}, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, isRulerLoading]);
}
function RuleStateCell({ rule }: { rule: CombinedRule }) {
const { isDeleting, isCreating, isPaused } = useRuleStatus(rule);
return <RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} isPaused={isPaused} />;
}
function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadingRuler: boolean }) {
const styles = useStyles2(getStyles);
const { isDeleting, isCreating } = useRuleStatus(rule);
if (isLoadingRuler) {
return <Skeleton containerClassName={styles.skeletonWrapper} />;
}
return (
<RuleActionsButtons
compact
showViewButton={!isDeleting && !isCreating}
rule={rule}
rulesSource={rule.namespace.rulesSource}
/>
);
}
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 && !rulerRuleType.grafana.rule(rulerRule)) {
return { isDeleting: false, isCreating: false, isPaused: false };
}
const isDeleting = Boolean(hasRuler && rulerRulesLoaded && promRule && !rulerRule);
const isCreating = Boolean(hasRuler && rulerRulesLoaded && rulerRule && !promRule);
const isPaused = rulerRuleType.grafana.rule(rulerRule) && isPausedRule(rulerRule);
return { isDeleting, isCreating, isPaused };
}