Alerting: alert list state view (#33020)

This commit is contained in:
Domas 2021-04-16 12:08:08 +03:00 committed by GitHub
parent 826d82fe95
commit 19c6a02f49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 362 additions and 184 deletions

View File

@ -135,6 +135,7 @@ export type IconName =
| 'user'
| 'users-alt'
| 'wrap-text'
| 'heart-rate'
| 'x';
export const getAvailableIcons = (): IconName[] => [

View File

@ -1,32 +1,41 @@
import { DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data';
import { Icon, InfoBox, useStyles, Button } from '@grafana/ui';
import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data';
import { Icon, InfoBox, useStyles, Button, ButtonGroup, ToolbarButton } from '@grafana/ui';
import { SerializedError } from '@reduxjs/toolkit';
import React, { FC, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA';
import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useFilteredRules } from './hooks/useFilteredRules';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
import {
getAllRulesSourceNames,
getRulesDataSources,
GRAFANA_RULES_SOURCE_NAME,
isCloudRulesSource,
} from './utils/datasource';
import { getAllRulesSourceNames, getRulesDataSources, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { css } from '@emotion/css';
import { ThresholdRules } from './components/rules/ThresholdRules';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
import { isRulerNotSupportedResponse } from './utils/rules';
import RulesFilter from './components/rules/RulesFilter';
import { RuleListGroupView } from './components/rules/RuleListGroupView';
import { RuleListStateView } from './components/rules/RuleListStateView';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
const VIEWS = {
groups: RuleListGroupView,
state: RuleListStateView,
};
export const RuleList: FC = () => {
const dispatch = useDispatch();
const styles = useStyles(getStyles);
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [queryParams] = useQueryParams();
const view = VIEWS[queryParams['view'] as keyof typeof VIEWS]
? (queryParams['view'] as keyof typeof VIEWS)
: 'groups';
const ViewComponent = VIEWS[view];
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
dispatch(fetchAllPromAndRulerRulesAction());
@ -75,19 +84,6 @@ export const RuleList: FC = () => {
const combinedNamespaces = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces);
const [thresholdNamespaces, systemNamespaces] = useMemo(() => {
const sorted = filteredNamespaces
.map((namespace) => ({
...namespace,
groups: namespace.groups.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return [
sorted.filter((ns) => ns.rulesSource === GRAFANA_RULES_SOURCE_NAME),
sorted.filter((ns) => isCloudRulesSource(ns.rulesSource)),
];
}, [filteredNamespaces]);
return (
<AlertingPageWrapper pageId="alert-list" isLoading={loading && !haveResults}>
{(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && (
@ -126,6 +122,18 @@ export const RuleList: FC = () => {
<RulesFilter />
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<ButtonGroup>
<a href={urlUtil.renderUrl('/alerting/list', { ...queryParams, view: 'group' })}>
<ToolbarButton variant={view === 'groups' ? 'active' : 'default'} icon="folder">
Groups
</ToolbarButton>
</a>
<a href={urlUtil.renderUrl('/alerting/list', { ...queryParams, view: 'state' })}>
<ToolbarButton variant={view === 'state' ? 'active' : 'default'} icon="heart-rate">
State
</ToolbarButton>
</a>
</ButtonGroup>
<div />
<a href="/alerting/new">
<Button icon="plus">New alert rule</Button>
@ -134,8 +142,7 @@ export const RuleList: FC = () => {
</>
)}
{showNewAlertSplash && <NoRulesSplash />}
{haveResults && <ThresholdRules namespaces={thresholdNamespaces} />}
{haveResults && <SystemOrApplicationRules namespaces={systemNamespaces} />}
{haveResults && <ViewComponent namespaces={filteredNamespaces} />}
</AlertingPageWrapper>
);
};

View File

@ -0,0 +1,31 @@
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import React, { FC, useMemo } from 'react';
import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
import { SystemOrApplicationRules } from './SystemOrApplicationRules';
import { ThresholdRules } from './ThresholdRules';
interface Props {
namespaces: CombinedRuleNamespace[];
}
export const RuleListGroupView: FC<Props> = ({ namespaces }) => {
const [thresholdNamespaces, systemNamespaces] = useMemo(() => {
const sorted = namespaces
.map((namespace) => ({
...namespace,
groups: namespace.groups.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return [
sorted.filter((ns) => isGrafanaRulesSource(ns.rulesSource)),
sorted.filter((ns) => isCloudRulesSource(ns.rulesSource)),
];
}, [namespaces]);
return (
<>
<ThresholdRules namespaces={thresholdNamespaces} />
<SystemOrApplicationRules namespaces={systemNamespaces} />
</>
);
};

View File

@ -0,0 +1,43 @@
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { capitalize } from 'lodash';
import React, { FC, useState } from 'react';
import { CollapseToggle } from '../CollapseToggle';
import { RulesTable } from './RulesTable';
interface Props {
rules: CombinedRule[];
state: PromAlertingRuleState;
defaultCollapsed?: boolean;
}
export const RuleListStateSection: FC<Props> = ({ rules, state, defaultCollapsed = false }) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const styles = useStyles(getStyles);
return (
<>
<h4 className={styles.header}>
<CollapseToggle
className={styles.collapseToggle}
size="xxl"
isCollapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
/>
{capitalize(state)} ({rules.length})
</h4>
{!collapsed && <RulesTable rules={rules} showGroupColumn={true} />}
</>
);
};
const getStyles = (theme: GrafanaTheme) => ({
collapseToggle: css`
vertical-align: middle;
`,
header: css`
margin-top: ${theme.spacing.md};
`,
});

View File

@ -0,0 +1,46 @@
import { CombinedRule, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import React, { FC, useMemo } from 'react';
import { isAlertingRule } from '../../utils/rules';
import { RuleListStateSection } from './RuleListSateSection';
interface Props {
namespaces: CombinedRuleNamespace[];
}
type GroupedRules = Record<PromAlertingRuleState, CombinedRule[]>;
export const RuleListStateView: FC<Props> = ({ namespaces }) => {
const groupedRules = useMemo(() => {
const result: GroupedRules = {
[PromAlertingRuleState.Firing]: [],
[PromAlertingRuleState.Inactive]: [],
[PromAlertingRuleState.Pending]: [],
};
namespaces.forEach((namespace) =>
namespace.groups.forEach((group) =>
group.rules.forEach((rule) => {
if (rule.promRule && isAlertingRule(rule.promRule)) {
result[rule.promRule.state].push(rule);
}
})
)
);
Object.values(result).forEach((rules) => rules.sort((a, b) => a.name.localeCompare(b.name)));
return result;
}, [namespaces]);
return (
<>
<RuleListStateSection state={PromAlertingRuleState.Firing} rules={groupedRules[PromAlertingRuleState.Firing]} />
<RuleListStateSection state={PromAlertingRuleState.Pending} rules={groupedRules[PromAlertingRuleState.Pending]} />
<RuleListStateSection
defaultCollapsed={true}
state={PromAlertingRuleState.Inactive}
rules={groupedRules[PromAlertingRuleState.Inactive]}
/>
</>
);
};

View File

@ -1,4 +1,4 @@
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import React, { FC, useMemo, useState, Fragment } from 'react';
import { Icon, Tooltip, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
@ -13,17 +13,17 @@ import { ActionIcon } from './ActionIcon';
import pluralize from 'pluralize';
import { useHasRuler } from '../../hooks/useHasRuler';
interface Props {
namespace: string;
rulesSource: RulesSource;
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
}
export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource }) => {
export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
const { rulesSource } = namespace;
const styles = useStyles(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
const hasRuler = useHasRuler(rulesSource);
const hasRuler = useHasRuler();
const stats = useMemo(
(): Record<PromAlertingRuleState, number> =>
@ -60,14 +60,14 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
}
const actionIcons: React.ReactNode[] = [];
if (hasRuler) {
if (hasRuler(rulesSource)) {
actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" />);
}
if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
actionIcons.push(<ActionIcon key="manage-perms" icon="lock" tooltip="manage permissions" />);
}
const groupName = isCloudRulesSource(rulesSource) ? `${namespace} > ${group.name}` : namespace;
const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
return (
<div className={styles.wrapper} data-testid="rule-group">
@ -105,7 +105,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
</>
)}
</div>
{!isCollapsed && <RulesTable rulesSource={rulesSource} namespace={namespace} group={group} />}
{!isCollapsed && <RulesTable showGuidelines={true} rules={group.rules} />}
</div>
);
});

View File

@ -1,40 +1,43 @@
import { GrafanaTheme, rangeUtil } from '@grafana/data';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmModal, useStyles } from '@grafana/ui';
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
import React, { FC, Fragment, useState } from 'react';
import { getRuleIdentifier, isAlertingRule, stringifyRuleIdentifier } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { css, cx } from '@emotion/css';
import { TimeToNow } from '../TimeToNow';
import { StateTag } from '../StateTag';
import { RuleDetails } from './RuleDetails';
import { getAlertTableStyles } from '../../styles/table';
import { ActionIcon } from './ActionIcon';
import { createExploreLink } from '../../utils/misc';
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { useDispatch } from 'react-redux';
import { deleteRuleAction } from '../../state/actions';
import { useHasRuler } from '../../hooks/useHasRuler';
import { CombinedRule } from 'app/types/unified-alerting';
interface Props {
namespace: string;
group: CombinedRuleGroup;
rulesSource: RulesSource;
rules: CombinedRule[];
showGuidelines?: boolean;
showGroupColumn?: boolean;
emptyMessage?: string;
}
export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
const { rules } = group;
export const RulesTable: FC<Props> = ({
rules,
showGuidelines = false,
emptyMessage = 'No rules found.',
showGroupColumn = false,
}) => {
const dispatch = useDispatch();
const hasRuler = useHasRuler(rulesSource);
const hasRuler = useHasRuler();
const styles = useStyles(getStyles);
const tableStyles = useStyles(getAlertTableStyles);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [ruleToDelete, setRuleToDelete] = useState<RulerRuleDTO>();
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const toggleExpandedState = (ruleKey: string) =>
setExpandedKeys(
@ -42,20 +45,29 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
);
const deleteRule = () => {
if (ruleToDelete) {
if (ruleToDelete && ruleToDelete.rulerRule) {
dispatch(
deleteRuleAction(getRuleIdentifier(getRulesSourceName(rulesSource), namespace, group.name, ruleToDelete))
deleteRuleAction(
getRuleIdentifier(
getRulesSourceName(ruleToDelete.namespace.rulesSource),
ruleToDelete.namespace.name,
ruleToDelete.group.name,
ruleToDelete.rulerRule
)
)
);
setRuleToDelete(undefined);
}
};
const wrapperClass = cx(styles.wrapper, { [styles.wrapperMargin]: showGuidelines });
if (!rules.length) {
return <div className={styles.wrapper}>Folder is empty.</div>;
return <div className={wrapperClass}>{emptyMessage}</div>;
}
return (
<div className={styles.wrapper}>
<div className={wrapperClass}>
<table className={tableStyles.table} data-testid="rules-table">
<colgroup>
<col className={styles.colExpand} />
@ -64,16 +76,17 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
<col />
<col />
<col />
{showGroupColumn && <col />}
</colgroup>
<thead>
<tr>
<th className={styles.relative}>
<div className={cx(styles.headerGuideline, styles.guideline)} />
{showGuidelines && <div className={cx(styles.headerGuideline, styles.guideline)} />}
</th>
<th>State</th>
<th>Name</th>
{showGroupColumn && <th>Group</th>}
<th>Status</th>
<th>Evaluation</th>
<th>Actions</th>
</tr>
</thead>
@ -81,6 +94,8 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
{(() => {
const seenKeys: string[] = [];
return rules.map((rule, ruleIdx) => {
const { namespace, group } = rule;
const { rulesSource } = namespace;
let key = JSON.stringify([rule.promRule?.type, rule.labels, rule.query, rule.name, rule.annotations]);
if (seenKeys.includes(key)) {
key += `-${ruleIdx}`;
@ -90,16 +105,20 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
const { promRule, rulerRule } = rule;
const statuses = [
promRule?.health,
hasRuler && promRule && !rulerRule ? 'deleting' : '',
hasRuler && rulerRule && !promRule ? 'creating' : '',
hasRuler(rulesSource) && promRule && !rulerRule ? 'deleting' : '',
hasRuler(rulesSource) && rulerRule && !promRule ? 'creating' : '',
].filter((x) => !!x);
return (
<Fragment key={key}>
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td className={styles.relative}>
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
{!(ruleIdx === rules.length - 1) && (
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
{showGuidelines && (
<>
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
{!(ruleIdx === rules.length - 1) && (
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
)}
</>
)}
<CollapseToggle
isCollapsed={!isExpanded}
@ -109,21 +128,14 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
</td>
<td>{promRule && isAlertingRule(promRule) ? <StateTag status={promRule.state} /> : 'n/a'}</td>
<td>{rule.name}</td>
{showGroupColumn && (
<td>{isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name}</td>
)}
<td>{statuses.join(', ') || 'n/a'}</td>
<td>
{promRule?.lastEvaluation && promRule.evaluationTime ? (
<>
<TimeToNow date={promRule.lastEvaluation} />, for{' '}
{rangeUtil.secondsToHms(promRule.evaluationTime)}
</>
) : (
'n/a'
)}
</td>
<td className={styles.actionsCell}>
{isCloudRulesSource(rulesSource) && (
<ActionIcon
icon="compass"
icon="chart-line"
tooltip="view in explore"
target="__blank"
href={createExploreLink(rulesSource.name, rule.query)}
@ -135,24 +147,24 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
tooltip="edit rule"
href={`/alerting/${encodeURIComponent(
stringifyRuleIdentifier(
getRuleIdentifier(getRulesSourceName(rulesSource), namespace, group.name, rulerRule)
getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule)
)
)}/edit`}
/>
)}
{!!rulerRule && (
<ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rulerRule)} />
<ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rule)} />
)}
</td>
</tr>
{isExpanded && (
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td className={styles.relative}>
{!(ruleIdx === rules.length - 1) && (
{!(ruleIdx === rules.length - 1) && showGuidelines && (
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
)}
</td>
<td colSpan={5}>
<td colSpan={showGroupColumn ? 5 : 4}>
<RuleDetails rulesSource={rulesSource} rule={rule} />
</td>
</tr>
@ -179,9 +191,11 @@ export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => {
};
export const getStyles = (theme: GrafanaTheme) => ({
wrapperMargin: css`
margin-left: 36px;
`,
wrapper: css`
margin-top: ${theme.spacing.md};
margin-left: 36px;
width: auto;
padding: ${theme.spacing.sm};
background-color: ${theme.colors.bg2};

View File

@ -36,17 +36,17 @@ export const SystemOrApplicationRules: FC<Props> = ({ namespaces }) => {
)}
</div>
{namespaces?.map(({ rulesSource, name, groups }) =>
groups.map((group) => (
{namespaces.map((namespace) => {
const { groups, rulesSource } = namespace;
return groups.map((group) => (
<RulesGroup
group={group}
key={`${getRulesSourceName(rulesSource)}-${name}-${group.name}`}
namespace={name}
rulesSource={rulesSource}
namespace={namespace}
/>
))
)}
{namespaces?.length === 0 && !dataSourcesLoading.length && !!rulesDataSources.length && <p>No rules found.</p>}
));
})}
{namespaces?.length === 0 && !!rulesDataSources.length && <p>No rules found.</p>}
{!rulesDataSources.length && <p>There are no Prometheus or Loki datas sources configured.</p>}
</section>
);

View File

@ -27,12 +27,7 @@ export const ThresholdRules: FC<Props> = ({ namespaces }) => {
{namespaces?.map((namespace) =>
namespace.groups.map((group) => (
<RulesGroup
group={group}
key={`${namespace.name}-${group.name}`}
namespace={namespace.name}
rulesSource={GRAFANA_RULES_SOURCE_NAME}
/>
<RulesGroup group={group} key={`${namespace.name}-${group.name}`} namespace={namespace} />
))
)}
{namespaces?.length === 0 && <p>No rules found.</p>}

View File

@ -3,10 +3,11 @@ import {
CombinedRuleGroup,
CombinedRuleNamespace,
Rule,
RuleGroup,
RuleNamespace,
RulesSource,
} from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useMemo, useRef } from 'react';
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules';
@ -26,109 +27,141 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
// cache results per rules source, so we only recalculate those for which results have actually changed
const cache = useRef<Record<string, CacheValue>>({});
return useMemo(() => {
const retv = getAllRulesSources()
.map((rulesSource): CombinedRuleNamespace[] => {
const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
const promRules = promRulesResponses[rulesSourceName]?.result;
const rulerRules = rulerRulesResponses[rulesSourceName]?.result;
return useMemo(
() =>
getAllRulesSources()
.map((rulesSource): CombinedRuleNamespace[] => {
const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
const promRules = promRulesResponses[rulesSourceName]?.result;
const rulerRules = rulerRulesResponses[rulesSourceName]?.result;
const cached = cache.current[rulesSourceName];
if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) {
return cached.result;
}
const namespaces: Record<string, CombinedRuleNamespace> = {};
const cached = cache.current[rulesSourceName];
if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) {
return cached.result;
}
const namespaces: Record<string, CombinedRuleNamespace> = {};
// first get all the ruler rules in
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
namespaces[namespaceName] = {
rulesSource,
name: namespaceName,
groups: groups.map((group) => ({
name: group.name,
rules: group.rules.map(
(rule): CombinedRule =>
isAlertingRulerRule(rule)
? {
name: rule.alert,
query: rule.expr,
labels: rule.labels || {},
annotations: rule.annotations || {},
rulerRule: rule,
}
: isRecordingRulerRule(rule)
? {
name: rule.record,
query: rule.expr,
labels: rule.labels || {},
annotations: {},
rulerRule: rule,
}
: {
name: rule.grafana_alert.title,
query: '',
labels: rule.grafana_alert.labels || {},
annotations: rule.grafana_alert.annotations || {},
rulerRule: rule,
}
),
})),
};
});
// then correlate with prometheus rules
promRules?.forEach(({ name: namespaceName, groups }) => {
const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
rulesSource,
name: namespaceName,
groups: [],
// first get all the ruler rules in
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
const namespace: CombinedRuleNamespace = {
rulesSource,
name: namespaceName,
groups: [],
};
namespaces[namespaceName] = namespace;
addRulerGroupsToCombinedNamespace(namespace, groups);
});
groups.forEach((group) => {
let combinedGroup = ns.groups.find((g) => g.name === group.name);
if (!combinedGroup) {
combinedGroup = {
name: group.name,
rules: [],
};
ns.groups.push(combinedGroup);
}
(group.rules ?? []).forEach((rule) => {
const existingRule = getExistingRuleInGroup(rule, combinedGroup!, rulesSource);
if (existingRule) {
existingRule.promRule = rule;
} else {
combinedGroup!.rules.push({
name: rule.name,
query: rule.query,
labels: rule.labels || {},
annotations: isAlertingRule(rule) ? rule.annotations || {} : {},
promRule: rule,
});
}
// then correlate with prometheus rules
promRules?.forEach(({ name: namespaceName, groups }) => {
const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
rulesSource,
name: namespaceName,
groups: [],
});
});
});
const result = Object.values(namespaces);
if (isGrafanaRulesSource(rulesSource)) {
// merge all groups in case of grafana
result.forEach((namespace) => {
namespace.groups = [
{
name: 'default',
rules: namespace.groups.flatMap((g) => g.rules).sort((a, b) => a.name.localeCompare(b.name)),
},
];
addPromGroupsToCombinedNamespace(ns, groups);
});
}
cache.current[rulesSourceName] = { promRules, rulerRules, result };
return result;
})
.flat();
return retv;
}, [promRulesResponses, rulerRulesResponses]);
const result = Object.values(namespaces);
if (isGrafanaRulesSource(rulesSource)) {
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as gorups
result.forEach((namespace) => {
namespace.groups = [
{
name: 'default',
rules: namespace.groups.flatMap((g) => g.rules).sort((a, b) => a.name.localeCompare(b.name)),
},
];
});
}
cache.current[rulesSourceName] = { promRules, rulerRules, result };
return result;
})
.flat(),
[promRulesResponses, rulerRulesResponses]
);
}
function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[]): void {
namespace.groups = groups.map((group) => {
const combinedGroup: CombinedRuleGroup = {
name: group.name,
rules: [],
};
combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup));
return combinedGroup;
});
}
function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RuleGroup[]): void {
groups.forEach((group) => {
let combinedGroup = namespace.groups.find((g) => g.name === group.name);
if (!combinedGroup) {
combinedGroup = {
name: group.name,
rules: [],
};
namespace.groups.push(combinedGroup);
}
(group.rules ?? []).forEach((rule) => {
const existingRule = getExistingRuleInGroup(rule, combinedGroup!, namespace.rulesSource);
if (existingRule) {
existingRule.promRule = rule;
} else {
combinedGroup!.rules.push(promRuleToCombinedRule(rule, namespace, combinedGroup!));
}
});
});
}
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,
};
}
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,
}
: isRecordingRulerRule(rule)
? {
name: rule.record,
query: rule.expr,
labels: rule.labels || {},
annotations: {},
rulerRule: rule,
namespace,
group,
}
: {
name: rule.grafana_alert.title,
query: '',
labels: rule.grafana_alert.labels || {},
annotations: rule.grafana_alert.annotations || {},
rulerRule: rule,
namespace,
group,
};
}
function getExistingRuleInGroup(

View File

@ -1,10 +1,16 @@
import { RulesSource } from 'app/types/unified-alerting';
import { useCallback } from 'react';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
// datasource has ruler if it's grafana managed or if we're able to load rules from it
export function useHasRuler(rulesSource: string | RulesSource): boolean {
export function useHasRuler(): (rulesSource: string | RulesSource) => boolean {
const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules);
const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name;
return rulesSourceName === GRAFANA_RULES_SOURCE_NAME || !!rulerRules[rulesSourceName]?.result;
return useCallback(
(rulesSource: string | RulesSource) => {
const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name;
return rulesSourceName === GRAFANA_RULES_SOURCE_NAME || !!rulerRules[rulesSourceName]?.result;
},
[rulerRules]
);
}

View File

@ -79,6 +79,8 @@ export interface CombinedRule {
annotations: Annotations;
promRule?: Rule;
rulerRule?: RulerRuleDTO;
group: CombinedRuleGroup;
namespace: CombinedRuleNamespace;
}
export interface CombinedRuleGroup {