Alerting: Add limits and move state and label matching filters to the BE (#66267)

* WIP

* Add instance totals to combined rule. Use totals to display instances stats in the UI

* WIP

* add global summaries, fix TS errors

* fix useCombined test

* fix test

* use activeAt from rule when available

* Fix NaN in global stats

* Add no data total to global summary

* Add totals recalculation for filtered rules

* Fix instances totals, remove instances filtering from alert list view

* Update tests

* Fetch alerts considering filtering label matchers

* WIP - Fetch alerts appending state filter to endpoint

* Fix multiple values for state in request being applyied

* fix test

* Calculate hidden by for grafana managed alerts

* Use INSTANCES_DISPLAY_LIMIT constant for limiting alert instances instead of 1

* Rename matchers parameter according to API changes

* Fix calculating total number of grafana instances

* Rename matcher prop after previous change

* Display button to remove max instances limit

* Change matcher query param to be an array of strings

* Add test for paramsWithMatcherAndState method

* Refactor matcher to be an string array to be consistent with state

* Use matcher query string as matcher object type (encoded JSON)

* Avoind encoding matcher parameters twice

* fix tests

* Enable toggle for the limit/show all button and restore limit and filters when we come back from custom view

* Move getMatcherListFromString method to utils/alertmanager.ts

* Fix limit toggle button being shown when it's not necessary

* Use filteredTotals from be response to calculate hidden by count

* Fix variables not being replaced correctly

* Fix total shown to be all the instances filtered without limits

* Adress some PR review comments

* Move paramsWithMatcherAndState inside prometheusUrlBuilder method

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>
This commit is contained in:
Sonia Aguilar
2023-04-25 11:19:20 +02:00
committed by GitHub
co-authored by Gilles De Mey Konrad Lalik Virginia Cepeda
parent 44035ecbb2
commit 64ee42d01e
31 changed files with 685 additions and 189 deletions
@@ -58,6 +58,7 @@ describe('AlertsFolderView tests', () => {
mockCombinedRule({ name: 'Test Alert 2' }),
mockCombinedRule({ name: 'Test Alert 3' }),
],
totals: {},
},
{
name: 'group2',
@@ -66,6 +67,7 @@ describe('AlertsFolderView tests', () => {
mockCombinedRule({ name: 'Test Alert 5' }),
mockCombinedRule({ name: 'Test Alert 6' }),
],
totals: {},
},
],
};
@@ -104,6 +106,7 @@ describe('AlertsFolderView tests', () => {
mockCombinedRule({ name: 'Test Alert from other folder 1' }),
mockCombinedRule({ name: 'Test Alert from other folder 2' }),
],
totals: {},
},
],
};
@@ -132,6 +135,7 @@ describe('AlertsFolderView tests', () => {
{
name: 'default',
rules: [mockCombinedRule({ name: 'CPU Alert' }), mockCombinedRule({ name: 'RAM usage alert' })],
totals: {},
},
],
};
@@ -166,6 +170,7 @@ describe('AlertsFolderView tests', () => {
mockCombinedRule({ name: 'CPU Alert', labels: {} }),
mockCombinedRule({ name: 'RAM usage alert', labels: { severity: 'critical' } }),
],
totals: {},
},
],
};
@@ -319,10 +319,16 @@ describe('PanelAlertTabContent', () => {
panelId: panel.id,
}
);
expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, {
dashboardUID: dashboard.uid,
panelId: panel.id,
});
expect(mocks.api.fetchRules).toHaveBeenCalledWith(
GRAFANA_RULES_SOURCE_NAME,
{
dashboardUID: dashboard.uid,
panelId: panel.id,
},
undefined,
undefined,
undefined
);
});
it('Update NewRuleFromPanel button url when template changes', async () => {
@@ -10,8 +10,8 @@ import { CombinedRule, Rule } from '../../../types/unified-alerting';
import { PromRuleType } from '../../../types/unified-alerting-dto';
import { RedirectToRuleViewer } from './RedirectToRuleViewer';
import { useCombinedRulesMatching } from './hooks/useCombinedRule';
import * as combinedRuleHooks from './hooks/useCombinedRule';
import { useCombinedRulesMatching } from './hooks/useCombinedRule';
import { getRulesSourceByName } from './utils/datasource';
jest.mock('./hooks/useCombinedRule');
@@ -119,6 +119,7 @@ const mockedRules: CombinedRule[] = [
group: {
name: 'test',
rules: [],
totals: {},
},
promRule: {
health: 'ok',
@@ -140,6 +141,8 @@ const mockedRules: CombinedRule[] = [
readOnly: false,
},
},
instanceTotals: {},
filteredInstanceTotals: {},
},
{
name: 'Cloud test alert',
@@ -149,6 +152,7 @@ const mockedRules: CombinedRule[] = [
group: {
name: 'test',
rules: [],
totals: {},
},
promRule: {
health: 'ok',
@@ -170,5 +174,7 @@ const mockedRules: CombinedRule[] = [
readOnly: false,
},
},
instanceTotals: {},
filteredInstanceTotals: {},
},
];
@@ -15,6 +15,7 @@ import { CombinedRuleNamespace } from '../../../types/unified-alerting';
import { LogMessages } from './Analytics';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA';
import { INSTANCES_DISPLAY_LIMIT } from './components/rules/RuleDetails';
import { RuleListErrors } from './components/rules/RuleListErrors';
import { RuleListGroupView } from './components/rules/RuleListGroupView';
import { RuleListStateView } from './components/rules/RuleListStateView';
@@ -34,6 +35,9 @@ const VIEWS = {
state: RuleListStateView,
};
// make sure we ask for 1 more so we show the "show x more" button
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
const RuleList = withErrorBoundary(
() => {
const dispatch = useDispatch();
@@ -68,17 +72,18 @@ const RuleList = withErrorBoundary(
);
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
const [_, fetchRules] = useAsyncFn(async () => {
if (!loading) {
await dispatch(fetchAllPromAndRulerRulesAction());
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}
}, [loading]);
}, [loading, limitAlerts, dispatch]);
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
dispatch(fetchAllPromAndRulerRulesAction());
}, [dispatch]);
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}, [dispatch, limitAlerts]);
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
// Show splash only when we loaded all of the data sources and none of them has alerts
@@ -108,7 +113,7 @@ const RuleList = withErrorBoundary(
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
<RuleStats namespaces={filteredNamespaces} includeTotal />
<RuleStats namespaces={filteredNamespaces} />
</div>
<Stack direction="row" gap={0.5}>
{canReadProvisioning && (
@@ -199,7 +199,11 @@ export function RuleViewer({ match }: RuleViewerProps) {
</div>
</div>
<div>
<RuleDetailsMatchingInstances rule={rule} pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }} />
<RuleDetailsMatchingInstances
rule={rule}
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
enableFiltering
/>
</div>
</RuleViewerLayoutContent>
<Collapse
@@ -0,0 +1,38 @@
import { paramsWithMatcherAndState } from './prometheus';
const matcher = [{ name: 'severity', isRegex: false, isEqual: true, value: 'critical' }];
const matcherToJson = matcher.map((m) => JSON.stringify(m));
const matchers = [...matcher, { name: 'label1', isRegex: false, isEqual: true, value: 'hello there' }];
const matchersToJson = matchers.map((m) => JSON.stringify(m));
describe('paramsWithMatcherAndState method', () => {
it('Should return same params object with no changes if there are no states nor matchers', () => {
const params: Record<string, string | string[]> = { hello: 'there', bye: 'bye' };
expect(paramsWithMatcherAndState(params)).toStrictEqual(params);
});
it('Should return params object with state if there are states and no matchers', () => {
const params: Record<string, string | string[]> = { hello: 'there', bye: 'bye' };
const state: string[] = ['firing', 'pending'];
expect(paramsWithMatcherAndState(params, state)).toStrictEqual({ ...params, state: state });
});
it('Should return params object with state if there are matchers and no states', () => {
const params: Record<string, string | string[]> = { hello: 'there', bye: 'bye' };
expect(paramsWithMatcherAndState(params, undefined, matcher)).toStrictEqual({
...params,
matcher: matcherToJson,
});
expect(paramsWithMatcherAndState(params, undefined, matchers)).toStrictEqual({
...params,
matcher: matchersToJson,
});
});
it('Should return params object with stateand matchers if there are states and matchers', () => {
const params: Record<string, string | string[]> = { hello: 'there', bye: 'bye' };
const state: string[] = ['firing', 'pending'];
expect(paramsWithMatcherAndState(params, state, matchers)).toStrictEqual({
...params,
state: state,
matcher: matchersToJson,
});
});
});
@@ -1,6 +1,7 @@
import { lastValueFrom } from 'rxjs';
import { getBackendSrv } from '@grafana/runtime';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleNamespace } from 'app/types/unified-alerting';
import { PromRulesResponse } from 'app/types/unified-alerting-dto';
@@ -13,19 +14,27 @@ export interface FetchPromRulesFilter {
export interface PrometheusDataSourceConfig {
dataSourceName: string;
limitAlerts?: number;
}
export function prometheusUrlBuilder(dataSourceConfig: PrometheusDataSourceConfig) {
const { dataSourceName } = dataSourceConfig;
const { dataSourceName, limitAlerts } = dataSourceConfig;
return {
rules: (filter?: FetchPromRulesFilter) => {
rules: (filter?: FetchPromRulesFilter, state?: string[], matcher?: Matcher[]) => {
const searchParams = new URLSearchParams();
// if we're fetching for Grafana managed rules, we should add a limit to the number of alert instances
// we do this because the response is large otherwise and we don't show all of them in the UI anyway.
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME && limitAlerts) {
searchParams.set('limit_alerts', String(limitAlerts));
}
const params = prepareRulesFilterQueryParams(searchParams, filter);
return {
url: `/api/prometheus/${getDatasourceAPIUid(dataSourceName)}/api/v1/rules`,
params: params,
params: paramsWithMatcherAndState(params, state, matcher),
};
},
};
@@ -45,17 +54,46 @@ export function prepareRulesFilterQueryParams(
return Object.fromEntries(params);
}
export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise<RuleNamespace[]> {
export function paramsWithMatcherAndState(
params: Record<string, string | string[]>,
state?: string[],
matchers?: Matcher[]
) {
let paramsResult = { ...params };
if (state?.length) {
paramsResult = { ...paramsResult, state };
}
if (matchers?.length) {
const matcherToJsonString: string[] = matchers.map((m) => JSON.stringify(m));
paramsResult = {
...paramsResult,
matcher: matcherToJsonString,
};
}
return paramsResult;
}
export async function fetchRules(
dataSourceName: string,
filter?: FetchPromRulesFilter,
limitAlerts?: number,
matcher?: Matcher[],
state?: string[]
): Promise<RuleNamespace[]> {
if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) {
throw new Error('Filtering by dashboard UID is only supported for Grafana Managed rules.');
}
const { url, params } = prometheusUrlBuilder({ dataSourceName }).rules(filter);
const { url, params } = prometheusUrlBuilder({ dataSourceName, limitAlerts }).rules(filter, state, matcher);
// adding state param here instead of adding it in prometheusUrlBuilder, for being a possible multiple query param
const response = await lastValueFrom(
getBackendSrv().fetch<PromRulesResponse>({
url,
params,
params: params,
showErrorAlert: false,
showSuccessAlert: false,
})
@@ -162,7 +162,7 @@ function FolderGroupAndEvaluationInterval({
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [],
};
const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [] };
const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [], totals: {} };
return (
<div>
@@ -60,7 +60,7 @@ describe('EditGroupModal', () => {
const namespace = mockCombinedRuleNamespace({
name: 'my-alerts',
rulesSource: mockDataSource(),
groups: [{ name: 'default-group', interval: '90s', rules: [] }],
groups: [{ name: 'default-group', interval: '90s', rules: [], totals: {} }],
});
const group = namespace.groups[0];
@@ -100,7 +100,9 @@ describe('EditGroupModal component on cloud alert rules', () => {
const promNs = mockCombinedRuleNamespace({
name: 'prometheus-ns',
rulesSource: promDsSettings,
groups: [{ name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2] }],
groups: [
{ name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2], totals: {} },
],
});
const group = promNs.groups[0];
@@ -121,7 +123,7 @@ describe('EditGroupModal component on cloud alert rules', () => {
const promNs = mockCombinedRuleNamespace({
name: 'prometheus-ns',
rulesSource: promDsSettings,
groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2] }],
groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2], totals: {} }],
});
const group = promNs.groups[0];
@@ -154,6 +156,7 @@ describe('EditGroupModal component on grafana-managed alert rules', () => {
rulerRule: mockRulerAlertingRule({ alert: 'high-memory' }),
}),
],
totals: {},
},
],
};
@@ -25,7 +25,7 @@ interface Props {
// The limit is set to 15 in order to upkeep the good performance
// and to encourage users to go to the rule details page to see the rest of the instances
// We don't want to paginate the instances list on the alert list page
const INSTANCES_DISPLAY_LIMIT = 15;
export const INSTANCES_DISPLAY_LIMIT = 15;
export const RuleDetails = ({ rule }: Props) => {
const styles = useStyles2(getStyles);
@@ -32,7 +32,7 @@ describe('RuleDetailsMatchingInstances', () => {
it('For Grafana Managed rules instances filter should contain five states', () => {
const rule = mockCombinedRule();
render(<RuleDetailsMatchingInstances rule={rule} />);
render(<RuleDetailsMatchingInstances rule={rule} enableFiltering />);
const stateFilter = ui.stateFilter.get();
expect(stateFilter).toBeInTheDocument();
@@ -69,7 +69,7 @@ describe('RuleDetailsMatchingInstances', () => {
[GrafanaAlertState.Error]: ui.grafanaStateButton.error,
};
render(<RuleDetailsMatchingInstances rule={rule} />);
render(<RuleDetailsMatchingInstances rule={rule} enableFiltering />);
await userEvent.click(buttons[state].get());
@@ -82,7 +82,7 @@ describe('RuleDetailsMatchingInstances', () => {
namespace: mockPromNamespace(),
});
render(<RuleDetailsMatchingInstances rule={rule} />);
render(<RuleDetailsMatchingInstances rule={rule} enableFiltering />);
const stateFilter = ui.stateFilter.get();
expect(stateFilter).toBeInTheDocument();
@@ -108,7 +108,7 @@ describe('RuleDetailsMatchingInstances', () => {
}),
});
render(<RuleDetailsMatchingInstances rule={rule} />);
render(<RuleDetailsMatchingInstances rule={rule} enableFiltering />);
await userEvent.click(ui.cloudStateButton[state].get());
@@ -122,7 +122,7 @@ describe('RuleDetailsMatchingInstances', () => {
function mockPromNamespace(): CombinedRuleNamespace {
return {
rulesSource: mockDataSource(),
groups: [{ name: 'Prom rules group', rules: [] }],
groups: [{ name: 'Prom rules group', rules: [], totals: {} }],
name: 'Prometheus-test',
};
}
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { countBy } from 'lodash';
import { countBy, sum } from 'lodash';
import React, { useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
@@ -20,11 +20,13 @@ import { isAlertingRule } from '../../utils/rules';
import { DetailsField } from '../DetailsField';
import { AlertInstancesTable } from './AlertInstancesTable';
import { getComponentsFromStats } from './RuleStats';
interface Props {
rule: CombinedRule;
pagination?: PaginationProps;
itemsDisplayLimit?: number;
enableFiltering?: boolean;
}
interface ShowMoreStats {
@@ -52,9 +54,10 @@ function ShowMoreInstances(props: { ruleViewPageLink: string; stats: ShowMoreSta
export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
const {
rule: { promRule, namespace },
rule: { promRule, namespace, instanceTotals },
itemsDisplayLimit = Number.POSITIVE_INFINITY,
pagination,
enableFiltering = false,
} = props;
const [queryString, setQueryString] = useState<string>();
@@ -82,40 +85,45 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
const visibleInstances = alerts.slice(0, itemsDisplayLimit);
// Count All By State is used only when filtering is enabled and we have access to all instances
const countAllByState = countBy(promRule.alerts, (alert) => mapStateWithReasonToBaseState(alert.state));
const hiddenItemsCount = alerts.length - visibleInstances.length;
const totalInstancesCount = sum(Object.values(instanceTotals));
const hiddenInstancesCount = totalInstancesCount - visibleInstances.length;
const stats: ShowMoreStats = {
totalItemsCount: alerts.length,
totalItemsCount: totalInstancesCount,
visibleItemsCount: visibleInstances.length,
};
const ruleViewPageLink = createViewLink(namespace.rulesSource, props.rule, location.pathname + location.search);
const statsComponents = getComponentsFromStats(instanceTotals);
const footerRow = hiddenItemsCount ? (
const footerRow = hiddenInstancesCount ? (
<ShowMoreInstances stats={stats} ruleViewPageLink={ruleViewPageLink} />
) : undefined;
return (
<DetailsField label="Matching instances" horizontal={true}>
<div className={cx(styles.flexRow, styles.spaceBetween)}>
<div className={styles.flexRow}>
<MatcherFilter
className={styles.rowChild}
key={queryStringKey}
defaultQueryString={queryString}
onFilterChange={(value) => setQueryString(value)}
/>
<AlertInstanceStateFilter
className={styles.rowChild}
filterType={stateFilterType}
stateFilter={alertState}
onStateFilterChange={setAlertState}
itemPerStateStats={countAllByState}
/>
{enableFiltering && (
<div className={cx(styles.flexRow, styles.spaceBetween)}>
<div className={styles.flexRow}>
<MatcherFilter
className={styles.rowChild}
key={queryStringKey}
defaultQueryString={queryString}
onFilterChange={(value) => setQueryString(value)}
/>
<AlertInstanceStateFilter
className={styles.rowChild}
filterType={stateFilterType}
stateFilter={alertState}
onStateFilterChange={setAlertState}
itemPerStateStats={countAllByState}
/>
</div>
</div>
</div>
)}
{!enableFiltering && <div className={styles.stats}>{statsComponents}</div>}
<AlertInstancesTable instances={visibleInstances} pagination={pagination} footerRow={footerRow} />
</DetailsField>
);
@@ -164,5 +172,13 @@ const getStyles = (theme: GrafanaTheme2) => {
align-items: center;
width: 100%;
`,
instancesContainer: css`
margin-bottom: ${theme.spacing(2)};
`,
stats: css`
display: flex;
gap: ${theme.spacing(1)};
padding: ${theme.spacing(1, 0)};
`,
};
};
@@ -116,6 +116,7 @@ function getGrafanaNamespace(): CombinedRuleNamespace {
{
name: 'default',
rules: [mockCombinedRule()],
totals: {},
},
],
};
@@ -129,6 +130,7 @@ function getCloudNamespace(): CombinedRuleNamespace {
{
name: 'Prom group',
rules: [mockCombinedRule()],
totals: {},
},
],
};
@@ -31,7 +31,7 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
promRule.state !== PromAlertingRuleState.Inactive
) {
// find earliest alert
const firstActiveAt = getFirstActiveAt(promRule);
const firstActiveAt = promRule.activeAt ? new Date(promRule.activeAt) : getFirstActiveAt(promRule);
// calculate time elapsed from earliest alert
if (firstActiveAt) {
@@ -1,121 +1,77 @@
import { isUndefined, omitBy, sum } from 'lodash';
import pluralize from 'pluralize';
import React, { Fragment, useState } from 'react';
import { useDebounce } from 'react-use';
import React, { Fragment } from 'react';
import { Stack } from '@grafana/experimental';
import { Badge } from '@grafana/ui';
import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import {
AlertGroupTotals,
AlertInstanceTotalState,
CombinedRuleGroup,
CombinedRuleNamespace,
} from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { isAlertingRule, isRecordingRule, isRecordingRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules';
interface Props {
includeTotal?: boolean;
group?: CombinedRuleGroup;
namespaces?: CombinedRuleNamespace[];
namespaces: CombinedRuleNamespace[];
}
const emptyStats = {
total: 0,
// All available states for a rule need to be initialized to prevent NaN values when adding a number and undefined
const emptyStats: Required<AlertGroupTotals> = {
recording: 0,
[PromAlertingRuleState.Firing]: 0,
alerting: 0,
[PromAlertingRuleState.Pending]: 0,
[PromAlertingRuleState.Inactive]: 0,
paused: 0,
error: 0,
} as const;
nodata: 0,
};
export const RuleStats = ({ group, namespaces, includeTotal }: Props) => {
const evaluationInterval = group?.interval;
const [calculated, setCalculated] = useState(emptyStats);
export const RuleStats = ({ namespaces }: Props) => {
const stats = { ...emptyStats };
// Performance optimization allowing reducing number of stats calculation
// The problem occurs when we load many data sources.
// Then redux store gets updated multiple times in a pretty short period, triggering calculating stats many times.
// debounce allows to skip calculations which results would be abandoned in milliseconds
useDebounce(
() => {
const stats = { ...emptyStats };
const calcRule = (rule: CombinedRule) => {
if (rule.promRule && isAlertingRule(rule.promRule)) {
if (isGrafanaRulerRulePaused(rule)) {
stats.paused += 1;
}
stats[rule.promRule.state] += 1;
}
if (ruleHasError(rule)) {
stats.error += 1;
}
if (
(rule.promRule && isRecordingRule(rule.promRule)) ||
(rule.rulerRule && isRecordingRulerRule(rule.rulerRule))
) {
stats.recording += 1;
}
stats.total += 1;
};
if (group) {
group.rules.forEach(calcRule);
// sum all totals for all namespaces
namespaces.forEach(({ groups }) => {
groups.forEach((group) => {
const groupTotals = omitBy(group.totals, isUndefined);
for (let key in groupTotals) {
// @ts-ignore
stats[key] += groupTotals[key];
}
});
});
if (namespaces) {
namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule)));
}
const statsComponents = getComponentsFromStats(stats);
const hasStats = Boolean(statsComponents.length);
setCalculated(stats);
},
400,
[group, namespaces]
const total = sum(Object.values(stats));
statsComponents.unshift(
<Fragment key="total">
{total} {pluralize('rule', total)}
</Fragment>
);
const statsComponents: React.ReactNode[] = [];
return (
<Stack direction="row">
{hasStats && (
<div>
<Stack gap={0.5}>{statsComponents}</Stack>
</div>
)}
</Stack>
);
};
if (includeTotal) {
statsComponents.push(
<Fragment key="total">
{calculated.total} {pluralize('rule', calculated.total)}
</Fragment>
);
}
interface RuleGroupStatsProps {
group: CombinedRuleGroup;
}
if (calculated[PromAlertingRuleState.Firing]) {
statsComponents.push(
<Badge color="red" key="firing" text={`${calculated[PromAlertingRuleState.Firing]} firing`} />
);
}
if (calculated.error) {
statsComponents.push(<Badge color="red" key="errors" text={`${calculated.error} errors`} />);
}
if (calculated[PromAlertingRuleState.Pending]) {
statsComponents.push(
<Badge color={'orange'} key="pending" text={`${calculated[PromAlertingRuleState.Pending]} pending`} />
);
}
if (calculated[PromAlertingRuleState.Inactive] && calculated.paused) {
statsComponents.push(
<Badge
color="green"
key="paused"
text={`${calculated[PromAlertingRuleState.Inactive]} normal (${calculated.paused} paused)`}
/>
);
}
if (calculated[PromAlertingRuleState.Inactive] && !calculated.paused) {
statsComponents.push(
<Badge color="green" key="inactive" text={`${calculated[PromAlertingRuleState.Inactive]} normal`} />
);
}
if (calculated.recording) {
statsComponents.push(<Badge color="purple" key="recording" text={`${calculated.recording} recording`} />);
}
export const RuleGroupStats = ({ group }: RuleGroupStatsProps) => {
const stats = group.totals;
const evaluationInterval = group?.interval;
const statsComponents = getComponentsFromStats(stats);
const hasStats = Boolean(statsComponents.length);
return (
@@ -135,6 +91,48 @@ export const RuleStats = ({ group, namespaces, includeTotal }: Props) => {
);
};
function ruleHasError(rule: CombinedRule) {
return rule.promRule?.health === 'err' || rule.promRule?.health === 'error';
export function getComponentsFromStats(
stats: Partial<Record<AlertInstanceTotalState | 'paused' | 'recording', number>>
) {
const statsComponents: React.ReactNode[] = [];
if (stats[AlertInstanceTotalState.Alerting]) {
statsComponents.push(<Badge color="red" key="firing" text={`${stats[AlertInstanceTotalState.Alerting]} firing`} />);
}
if (stats.error) {
statsComponents.push(<Badge color="red" key="errors" text={`${stats.error} errors`} />);
}
if (stats.nodata) {
statsComponents.push(<Badge color="blue" key="nodata" text={`${stats.nodata} no data`} />);
}
if (stats[AlertInstanceTotalState.Pending]) {
statsComponents.push(
<Badge color={'orange'} key="pending" text={`${stats[AlertInstanceTotalState.Pending]} pending`} />
);
}
if (stats[AlertInstanceTotalState.Normal] && stats.paused) {
statsComponents.push(
<Badge
color="green"
key="paused"
text={`${stats[AlertInstanceTotalState.Normal]} normal (${stats.paused} paused)`}
/>
);
}
if (stats[AlertInstanceTotalState.Normal] && !stats.paused) {
statsComponents.push(
<Badge color="green" key="inactive" text={`${stats[AlertInstanceTotalState.Normal]} normal`} />
);
}
if (stats.recording) {
statsComponents.push(<Badge color="purple" key="recording" text={`${stats.recording} recording`} />);
}
return statsComponents;
}
@@ -62,6 +62,7 @@ describe('Rules group tests', () => {
const group: CombinedRuleGroup = {
name: 'TestGroup',
rules: [mockCombinedRule()],
totals: {},
};
const namespace: CombinedRuleNamespace = {
@@ -89,6 +90,7 @@ describe('Rules group tests', () => {
const group: CombinedRuleGroup = {
name: 'TestGroup',
rules: [mockCombinedRule()],
totals: {},
};
const namespace: CombinedRuleNamespace = {
@@ -147,6 +149,7 @@ describe('Rules group tests', () => {
const group: CombinedRuleGroup = {
name: 'TestGroup',
rules: [mockCombinedRule()],
totals: {},
};
const namespace: CombinedRuleNamespace = {
@@ -23,7 +23,7 @@ import { RuleLocation } from '../RuleLocation';
import { ActionIcon } from './ActionIcon';
import { EditCloudGroupModal } from './EditRuleGroupModal';
import { ReorderCloudGroupModal } from './ReorderRuleGroupModal';
import { RuleStats } from './RuleStats';
import { RuleGroupStats } from './RuleStats';
import { RulesTable } from './RulesTable';
type ViewMode = 'grouped' | 'list';
@@ -217,7 +217,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
}
<div className={styles.spacer} />
<div className={styles.headerStats}>
<RuleStats group={group} />
<RuleGroupStats group={group} />
</div>
{isProvisioned && (
<>
@@ -8,22 +8,26 @@ describe('flattenGrafanaManagedRules', () => {
const ungroupedGroup1 = {
name: 'my-rule',
rules: [{ name: 'my-rule' }],
totals: {},
} as CombinedRuleGroup;
const ungroupedGroup2 = {
name: 'another-rule',
rules: [{ name: 'another-rule' }],
totals: {},
} as CombinedRuleGroup;
// the rules from both these groups should go in their own group name
const group1 = {
name: 'group1',
rules: [{ name: 'rule-1' }, { name: 'rule-2' }],
totals: {},
} as CombinedRuleGroup;
const group2 = {
name: 'group2',
rules: [{ name: 'rule-1' }, { name: 'rule-2' }],
totals: {},
} as CombinedRuleGroup;
const namespace1 = {
@@ -45,6 +49,7 @@ describe('flattenGrafanaManagedRules', () => {
{
name: 'default',
rules: sortRulesByName([...ungroupedGroup1.rules, ...ungroupedGroup2.rules, ...group1.rules, ...group2.rules]),
totals: {},
},
]);
@@ -52,6 +57,7 @@ describe('flattenGrafanaManagedRules', () => {
{
name: 'default',
rules: ungroupedGroup1.rules,
totals: {},
},
]);
});
@@ -1,7 +1,11 @@
import { isEqual } from 'lodash';
import { countBy, isEqual } from 'lodash';
import { useMemo, useRef } from 'react';
import {
AlertGroupTotals,
AlertingRule,
AlertInstanceTotals,
AlertInstanceTotalState,
CombinedRule,
CombinedRuleGroup,
CombinedRuleNamespace,
@@ -10,7 +14,12 @@ import {
RuleNamespace,
RulesSource,
} from 'app/types/unified-alerting';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import {
PromAlertingRuleState,
RulerRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import {
getAllRulesSources,
@@ -18,7 +27,13 @@ import {
isCloudRulesSource,
isGrafanaRulesSource,
} from '../utils/datasource';
import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules';
import {
isAlertingRule,
isAlertingRulerRule,
isGrafanaRulerRule,
isRecordingRule,
isRecordingRulerRule,
} from '../utils/rules';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
@@ -104,6 +119,7 @@ export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[])
newNamespace.groups.push({
name: 'default',
rules: sortRulesByName(namespace.groups.flatMap((group) => group.rules)),
totals: calculateAllGroupsTotals(namespace.groups),
});
return newNamespace;
@@ -116,11 +132,18 @@ export function sortRulesByName(rules: CombinedRule[]) {
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;
@@ -137,11 +160,18 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou
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<string, CombinedRule[]>();
combinedGroup!.rules.forEach((r) => {
// Prometheus rules do not have to be unique by name
@@ -153,6 +183,8 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou
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!));
}
@@ -160,6 +192,74 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou
});
}
export function calculateRuleTotals(rule: Pick<AlertingRule, 'alerts' | 'totals'>): AlertInstanceTotals {
const result = countBy(rule.alerts, 'state');
if (rule.totals) {
const { normal, ...totals } = rule.totals;
return { ...totals, inactive: normal };
}
return {
alerting: result[AlertInstanceTotalState.Alerting],
pending: result[AlertInstanceTotalState.Pending],
inactive: result[AlertInstanceTotalState.Normal],
nodata: result[AlertInstanceTotalState.NoData],
error: result[AlertInstanceTotalState.Error] + result['err'], // Prometheus uses "err" instead of "error"
};
}
export function calculateRuleFilteredTotals(
rule: Pick<AlertingRule, 'alerts' | 'totalsFiltered'>
): AlertInstanceTotals {
if (rule.totalsFiltered) {
const { normal, ...totals } = rule.totalsFiltered;
return { ...totals, inactive: normal };
}
return {};
}
export function calculateGroupTotals(group: Pick<RuleGroup, 'rules' | 'totals'>): 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<string, number> = {};
groups.forEach((group) => {
const groupTotals = group.totals;
Object.entries(groupTotals).forEach(([key, value]) => {
if (!totals[key]) {
totals[key] = 0;
}
totals[key] += value;
});
});
return totals;
}
function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, group: CombinedRuleGroup): CombinedRule {
return {
name: rule.name,
@@ -169,6 +269,8 @@ function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, gr
promRule: rule,
namespace: namespace,
group,
instanceTotals: isAlertingRule(rule) ? calculateRuleTotals(rule) : {},
filteredInstanceTotals: isAlertingRule(rule) ? calculateRuleFilteredTotals(rule) : {},
};
}
@@ -186,6 +288,8 @@ function rulerRuleToCombinedRule(
rulerRule: rule,
namespace,
group,
instanceTotals: {},
filteredInstanceTotals: {},
}
: isRecordingRulerRule(rule)
? {
@@ -196,6 +300,8 @@ function rulerRuleToCombinedRule(
rulerRule: rule,
namespace,
group,
instanceTotals: {},
filteredInstanceTotals: {},
}
: {
name: rule.grafana_alert.title,
@@ -205,6 +311,8 @@ function rulerRuleToCombinedRule(
rulerRule: rule,
namespace,
group,
instanceTotals: {},
filteredInstanceTotals: {},
};
}
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo } from 'react';
import { getDataSourceSrv } from '@grafana/runtime';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unified-alerting';
import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser';
@@ -13,6 +13,7 @@ import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers
import { isCloudRulesSource } from '../utils/datasource';
import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules';
import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces';
import { useURLSearchParams } from './useURLSearchParams';
export function useRulesFilter() {
@@ -74,7 +75,27 @@ export function useRulesFilter() {
}
export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => {
return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]);
return useMemo(() => {
const filteredRules = filterRules(namespaces, filterState);
// Totals recalculation is a workaround for the lack of server-side filtering
filteredRules.forEach((namespace) => {
namespace.groups.forEach((group) => {
group.rules.forEach((rule) => {
if (isAlertingRule(rule.promRule)) {
rule.instanceTotals = calculateRuleTotals(rule.promRule);
rule.filteredInstanceTotals = calculateRuleFilteredTotals(rule.promRule);
}
});
group.totals = calculateGroupTotals({
rules: group.rules.map((r) => r.promRule).filter((r): r is Rule => !!r),
});
});
});
return filteredRules;
}, [namespaces, filterState]);
};
// Options details can be found here https://github.com/leeoniya/uFuzzy#options
@@ -170,6 +170,7 @@ export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): Alert
},
state: PromAlertingRuleState.Firing,
health: 'OK',
totalsFiltered: { alerting: 1 },
...partial,
};
};
@@ -512,16 +513,19 @@ export const mockCombinedRule = (partial?: Partial<CombinedRule>): CombinedRule
group: {
name: 'mockCombinedRuleGroup',
rules: [],
totals: {},
},
namespace: {
name: 'mockCombinedNamespace',
groups: [{ name: 'mockCombinedRuleGroup', rules: [] }],
groups: [{ name: 'mockCombinedRuleGroup', rules: [], totals: {} }],
rulesSource: 'grafana',
},
labels: {},
annotations: {},
promRule: mockPromAlertingRule(),
rulerRule: mockRulerAlertingRule(),
instanceTotals: {},
filteredInstanceTotals: {},
...partial,
});
@@ -596,7 +600,7 @@ export function mockAlertQuery(query: Partial<AlertQuery>): AlertQuery {
}
export function mockCombinedRuleGroup(name: string, rules: CombinedRule[]): CombinedRuleGroup {
return { name, rules };
return { name, rules, totals: {} };
}
export function mockCombinedRuleNamespace(namespace: Partial<CombinedRuleNamespace>): CombinedRuleNamespace {
@@ -8,6 +8,7 @@ import {
AlertmanagerGroup,
ExternalAlertmanagerConfig,
ExternalAlertmanagersResponse,
Matcher,
Receiver,
Silence,
SilenceCreatePayload,
@@ -101,7 +102,19 @@ function getDataSourceRulerConfig(getState: () => unknown, rulesSourceName: stri
export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules',
async (
{ rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter },
{
rulesSourceName,
filter,
limitAlerts,
matcher,
state,
}: {
rulesSourceName: string;
filter?: FetchPromRulesFilter;
limitAlerts?: number;
matcher?: Matcher[];
state?: string[];
},
thunkAPI
): Promise<RuleNamespace[]> => {
await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName }));
@@ -111,7 +124,7 @@ export const fetchPromRulesAction = createAsyncThunk(
thunk: 'unifiedalerting/fetchPromRules',
});
return await withSerializedError(fetchRulesWithLogging(rulesSourceName, filter));
return await withSerializedError(fetchRulesWithLogging(rulesSourceName, filter, limitAlerts, matcher, state));
}
);
@@ -339,7 +352,17 @@ export const fetchRulesSourceBuildInfoAction = createAsyncThunk(
}
);
export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<Promise<void>> {
interface FetchPromRulesRulesActionProps {
filter?: FetchPromRulesFilter;
limitAlerts?: number;
matcher?: Matcher[];
state?: string[];
}
export function fetchAllPromAndRulerRulesAction(
force = false,
options: FetchPromRulesRulesActionProps = {}
): ThunkResult<Promise<void>> {
return async (dispatch, getStore) => {
const allStartLoadingTs = performance.now();
@@ -359,7 +382,7 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<Prom
(force || !rulerRules[rulesSourceName]?.loading) && Boolean(dataSourceConfig.rulerConfig);
await Promise.allSettled([
shouldLoadProm && dispatch(fetchPromRulesAction({ rulesSourceName })),
shouldLoadProm && dispatch(fetchPromRulesAction({ rulesSourceName, ...options })),
shouldLoadRuler && dispatch(fetchRulerRulesAction({ rulesSourceName })),
]);
})
@@ -3,12 +3,12 @@ import { isEqual, uniqWith } from 'lodash';
import { SelectableValue } from '@grafana/data';
import {
AlertManagerCortexConfig,
MatcherOperator,
Route,
Matcher,
MatcherOperator,
ObjectMatcher,
Route,
TimeInterval,
TimeRange,
ObjectMatcher,
} from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
@@ -16,6 +16,7 @@ describe('alertRuleToQueries', () => {
group: {
name: 'Prom up alert',
rules: [],
totals: {},
},
namespace: {
rulesSource: GRAFANA_RULES_SOURCE_NAME,
@@ -28,6 +29,8 @@ describe('alertRuleToQueries', () => {
labels: {},
grafana_alert: grafanaAlert,
},
instanceTotals: {},
filteredInstanceTotals: {},
};
const result = alertRuleToQueries(combinedRule);
@@ -43,6 +46,7 @@ describe('alertRuleToQueries', () => {
group: {
name: 'test',
rules: [],
totals: {},
},
namespace: {
name: 'prom test alerts',
@@ -58,6 +62,8 @@ describe('alertRuleToQueries', () => {
readOnly: false,
},
},
instanceTotals: {},
filteredInstanceTotals: {},
};
const result = alertRuleToQueries(combinedRule);