import { css } from '@emotion/css'; import pluralize from 'pluralize'; import React, { useEffect, useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; import { LogMessages, logInfo } from '../../Analytics'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; import { useRulesAccess } from '../../utils/accessControlHooks'; import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter'; import { decodeGrafanaNamespace } from '../expressions/util'; import { ActionIcon } from './ActionIcon'; import { EditRuleGroupModal } from './EditRuleGroupModal'; import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; import { RuleGroupStats } from './RuleStats'; import { RulesTable, useIsRulesLoading } from './RulesTable'; type ViewMode = 'grouped' | 'list'; interface Props { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; expandAll: boolean; viewMode: ViewMode; } const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { const { rulesSource } = namespace; const rulesSourceName = getRulesSourceName(rulesSource); const rulerRulesLoaded = useIsRulesLoading(rulesSource); const [deleteRuleGroup] = useDeleteRuleGroup(); const styles = useStyles2(getStyles); const [isEditingGroup, setIsEditingGroup] = useState(false); const [isDeletingGroup, setIsDeletingGroup] = useState(false); const [isReorderingGroup, setIsReorderingGroup] = useState(false); const [isExporting, setIsExporting] = useState<'group' | 'folder' | undefined>(undefined); const [isCollapsed, setIsCollapsed] = useState(!expandAll); const { canEditRules } = useRulesAccess(); useEffect(() => { setIsCollapsed(!expandAll); }, [expandAll]); const { hasRuler, rulerConfig } = useHasRuler(namespace.rulesSource); const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName }); const rulerRule = group.rules[0]?.rulerRule; const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const { folder } = useFolder(folderUID); // group "is deleting" if rules source has ruler, but this group has no rules that are in ruler const isDeleting = hasRuler && rulerRulesLoaded && !group.rules.find((rule) => !!rule.rulerRule); const isFederated = isFederatedRuleGroup(group); // check if group has provisioned items const isProvisioned = group.rules.some((rule) => { return isGrafanaRulerRule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance; }); // check what view mode we are in const isListView = viewMode === 'list'; const isGroupView = viewMode === 'grouped'; const ruleGroupIdentifier = useMemo(() => { const namespaceName = namespace.uid ?? namespace.name; const groupName = group.name; const dataSourceName = getRulesSourceName(namespace.rulesSource); return { namespaceName, groupName, dataSourceName }; }, [namespace, group.name]); const deleteGroup = async () => { await deleteRuleGroup.execute(ruleGroupIdentifier); setIsDeletingGroup(false); }; const actionIcons: React.ReactNode[] = []; // for grafana, link to folder views if (isDeleting) { actionIcons.push( deleting ); } else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) { if (folderUID) { const baseUrl = makeFolderLink(folderUID); if (folder?.canSave) { if (isGroupView && !isProvisioned) { actionIcons.push( setIsEditingGroup(true)} /> ); actionIcons.push( setIsReorderingGroup(true)} /> ); } if (isListView) { actionIcons.push( ); if (folder?.canAdmin) { actionIcons.push( ); } } } if (folder) { if (isListView) { actionIcons.push( setIsExporting('folder')} /> ); } else if (isGroupView) { actionIcons.push( setIsExporting('group')} /> ); } } } } else if (canEditRules(rulesSource.name) && hasRuler) { if (!isFederated) { actionIcons.push( setIsEditingGroup(true)} /> ); actionIcons.push( setIsReorderingGroup(true)} /> ); } actionIcons.push( setIsDeletingGroup(true)} /> ); } // ungrouped rules are rules that are in the "default" group name const groupName = isListView ? ( ) : ( ); const closeEditModal = (saved = false) => { if (!saved) { logInfo(LogMessages.leavingRuleGroupEdit); } setIsEditingGroup(false); }; return (
{ // eslint-disable-next-line
setIsCollapsed(!isCollapsed)}> {isFederated && } {groupName}
}
{isProvisioned && ( <>
|
)} {!!actionIcons.length && ( <>
|
{actionIcons}
)}
{!isCollapsed && ( )} {isEditingGroup && rulerConfig && ( closeEditModal()} folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder.uid) : undefined} /> )} {isReorderingGroup && dsFeatures?.rulerConfig && ( setIsReorderingGroup(false)} rulerConfig={dsFeatures.rulerConfig} /> )}

Deleting "{group.name}" will permanently remove the group and{' '} {group.rules.length} alert {pluralize('rule', group.rules.length)} belonging to it.

Are you sure you want to delete this group?

} onConfirm={deleteGroup} onDismiss={() => setIsDeletingGroup(false)} confirmText="Delete" /> {folder && isExporting === 'folder' && ( setIsExporting(undefined)} /> )} {folder && isExporting === 'group' && ( setIsExporting(undefined)} /> )}
); }); RulesGroup.displayName = 'RulesGroup'; // It's a simple component but we render 80 of them on the list page it needs to be fast // The Tooltip component is expensive to render and the rulesSource doesn't change often // so memoization seems to bring a lot of benefit here const CloudSourceLogo = React.memo(({ rulesSource }: { rulesSource: RulesSource | string }) => { const styles = useStyles2(getStyles); if (isCloudRulesSource(rulesSource)) { return ( {rulesSource.meta.name} ); } return null; }); CloudSourceLogo.displayName = 'CloudSourceLogo'; // We render a lot of these on the list page, and the Icon component does quite a bit of work // to render its contents const FolderIcon = React.memo(({ isCollapsed }: { isCollapsed: boolean }) => { return ; }); FolderIcon.displayName = 'FolderIcon'; export const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css({}), header: css({ display: 'flex', flexDirection: 'row', alignItems: 'center', padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`, flexWrap: 'nowrap', borderBottom: `1px solid ${theme.colors.border.weak}`, '&:hover': { backgroundColor: theme.components.table.rowHoverBackground, }, }), headerStats: css({ flexShrink: 0, span: { verticalAlign: 'middle', }, [theme.breakpoints.down('sm')]: { order: 2, width: '100%', paddingLeft: theme.spacing(1), }, }), groupName: css({ marginLeft: theme.spacing(1), marginBottom: 0, cursor: 'pointer', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }), spacer: css({ flex: 1, }), collapseToggle: css({ background: 'none', border: 'none', marginTop: `-${theme.spacing(1)}`, marginBottom: `-${theme.spacing(1)}`, svg: { marginBottom: 0, }, }), dataSourceIcon: css({ width: theme.spacing(2), height: theme.spacing(2), marginLeft: theme.spacing(2), }), dataSourceOrigin: css({ marginRight: '1em', color: theme.colors.text.disabled, }), actionsSeparator: css({ margin: `0 ${theme.spacing(2)}`, }), actionIcons: css({ width: '80px', alignItems: 'center', flexShrink: 0, }), rulesTable: css({ margin: theme.spacing(2, 0), }), rotate90: css({ transform: 'rotate(90deg)', }), }; };