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
parent 44035ecbb2
commit 64ee42d01e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 685 additions and 189 deletions

View File

@ -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: {},
},
],
};

View File

@ -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 () => {

View File

@ -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: {},
},
];

View File

@ -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 && (

View File

@ -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

View File

@ -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,
});
});
});

View File

@ -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,
})

View File

@ -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>

View File

@ -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: {},
},
],
};

View File

@ -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);

View File

@ -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',
};
}

View File

@ -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)};
`,
};
};

View File

@ -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: {},
},
],
};

View File

@ -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) {

View File

@ -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;
}

View File

@ -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 = {

View File

@ -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 && (
<>

View File

@ -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: {},
},
]);
});

View File

@ -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: {},
};
}

View File

@ -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

View File

@ -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 {

View File

@ -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 })),
]);
})

View File

@ -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';

View File

@ -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);

View File

@ -4,8 +4,9 @@ import pluralize from 'pluralize';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, PanelProps } from '@grafana/data';
import { clearButtonStyles, Icon, useStyles2 } from '@grafana/ui';
import { Button, clearButtonStyles, Icon, useStyles2 } from '@grafana/ui';
import { AlertInstancesTable } from 'app/features/alerting/unified/components/rules/AlertInstancesTable';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
import { sortAlerts } from 'app/features/alerting/unified/utils/misc';
import { Alert } from 'app/types/unified-alerting';
@ -17,9 +18,20 @@ import { filterAlerts } from './util';
interface Props {
alerts: Alert[];
options: PanelProps<UnifiedAlertListOptions>['options'];
grafanaTotalInstances?: number;
grafanaFilteredInstancesTotal?: number;
handleInstancesLimit?: (limit: boolean) => void;
limitInstances?: boolean;
}
export const AlertInstances = ({ alerts, options }: Props) => {
export const AlertInstances = ({
alerts,
options,
grafanaTotalInstances,
handleInstancesLimit,
limitInstances,
grafanaFilteredInstancesTotal,
}: Props) => {
// when custom grouping is enabled, we will always uncollapse the list of alert instances
const defaultShowInstances = options.groupMode === GroupMode.Custom ? true : options.showInstances;
const [displayInstances, setDisplayInstances] = useState<boolean>(defaultShowInstances);
@ -36,8 +48,13 @@ export const AlertInstances = ({ alerts, options }: Props) => {
(): Alert[] => filterAlerts(options, sortAlerts(options.sortOrder, alerts)) ?? [],
[alerts, options]
);
const isGrafanaAlert = grafanaTotalInstances !== undefined;
const hiddenInstances = alerts.length - filteredAlerts.length;
const hiddenInstancesForGrafanaAlerts =
grafanaTotalInstances && grafanaFilteredInstancesTotal ? grafanaTotalInstances - grafanaFilteredInstancesTotal : 0;
const hiddenInstancesForNonGrafanaAlerts = alerts.length - filteredAlerts.length;
const hiddenInstances = isGrafanaAlert ? hiddenInstancesForGrafanaAlerts : hiddenInstancesForNonGrafanaAlerts;
const uncollapsible = filteredAlerts.length > 0;
const toggleShowInstances = uncollapsible ? toggleDisplayInstances : noop;
@ -48,6 +65,49 @@ export const AlertInstances = ({ alerts, options }: Props) => {
}
}, [filteredAlerts]);
const onShowAllClick = async () => {
if (!handleInstancesLimit) {
return;
}
handleInstancesLimit(false);
setDisplayInstances(true);
};
const onShowLimitedClick = async () => {
if (!handleInstancesLimit) {
return;
}
handleInstancesLimit(true);
setDisplayInstances(true);
};
const totalInstancesNumber = limitInstances ? grafanaFilteredInstancesTotal : filteredAlerts.length;
const limitStatus = limitInstances
? `Showing ${INSTANCES_DISPLAY_LIMIT} of ${grafanaTotalInstances} instances`
: `Showing all ${grafanaTotalInstances} instances`;
const limitButtonLabel = limitInstances
? 'View all instances'
: `Limit the result to ${INSTANCES_DISPLAY_LIMIT} instances`;
const instancesLimitedAndOverflowed =
grafanaTotalInstances &&
INSTANCES_DISPLAY_LIMIT === filteredAlerts.length &&
grafanaTotalInstances > filteredAlerts.length;
const instancesNotLimitedAndoverflowed =
grafanaTotalInstances && INSTANCES_DISPLAY_LIMIT < filteredAlerts.length && !limitInstances;
const footerRow =
instancesLimitedAndOverflowed || instancesNotLimitedAndoverflowed ? (
<div className={styles.footerRow}>
<div>{limitStatus}</div>
{
<Button size="sm" variant="secondary" onClick={limitInstances ? onShowAllClick : onShowLimitedClick}>
{limitButtonLabel}
</Button>
}
</div>
) : undefined;
return (
<div>
{options.groupMode === GroupMode.Default && (
@ -56,7 +116,7 @@ export const AlertInstances = ({ alerts, options }: Props) => {
onClick={() => toggleShowInstances()}
>
{uncollapsible && <Icon name={displayInstances ? 'angle-down' : 'angle-right'} size={'md'} />}
<span>{`${filteredAlerts.length} ${pluralize('instance', filteredAlerts.length)}`}</span>
<span>{`${totalInstancesNumber} ${pluralize('instance', totalInstancesNumber)}`}</span>
{hiddenInstances > 0 && <span>, {`${hiddenInstances} hidden by filters`}</span>}
</button>
)}
@ -64,14 +124,23 @@ export const AlertInstances = ({ alerts, options }: Props) => {
<AlertInstancesTable
instances={filteredAlerts}
pagination={{ itemsPerPage: 2 * DEFAULT_PER_PAGE_PAGINATION }}
footerRow={footerRow}
/>
)}
</div>
);
};
const getStyles = (_: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2) => ({
clickable: css`
cursor: pointer;
`,
footerRow: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
justify-content: space-between;
align-items: center;
width: 100%;
`,
});

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { sortBy } from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { useEffectOnce } from 'react-use';
import { useEffectOnce, useToggle } from 'react-use';
import { GrafanaTheme2, PanelProps } from '@grafana/data';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
@ -18,9 +18,11 @@ import {
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import alertDef from 'app/features/alerting/state/alertDef';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction } from 'app/features/alerting/unified/state/actions';
import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import {
getAllRulesSourceNames,
@ -37,14 +39,26 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { getAlertingRule } from '../../../features/alerting/unified/utils/rules';
import { AlertingRule, CombinedRuleWithLocation } from '../../../types/unified-alerting';
import { GroupMode, SortOrder, UnifiedAlertListOptions, ViewMode } from './types';
import { GroupMode, SortOrder, StateFilter, UnifiedAlertListOptions, ViewMode } from './types';
import GroupedModeView from './unified-alerting/GroupedView';
import UngroupedModeView from './unified-alerting/UngroupedView';
import { filterAlerts } from './util';
function getStateList(state: StateFilter) {
const reducer = (list: string[], [stateKey, value]: [string, boolean]) => {
if (Boolean(value)) {
return [...list, stateKey];
} else {
return list;
}
};
return Object.entries(state).reduce(reducer, []);
}
export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
const dispatch = useDispatch();
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [limitInstances, toggleLimit] = useToggle(true);
// backwards compat for "Inactive" state filter
useEffect(() => {
@ -60,14 +74,74 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
dashboard = getDashboardSrv().getCurrent();
});
const stateList = useMemo(() => getStateList(props.options.stateFilter), [props.options.stateFilter]);
const { options, replaceVariables } = props;
const parsedOptions: UnifiedAlertListOptions = {
...props.options,
alertName: replaceVariables(options.alertName),
alertInstanceLabelFilter: replaceVariables(options.alertInstanceLabelFilter),
};
const matcherList = useMemo(
() => parseMatchers(parsedOptions.alertInstanceLabelFilter),
[parsedOptions.alertInstanceLabelFilter]
);
useEffect(() => {
if (props.options.groupMode === GroupMode.Default) {
dispatch(
fetchAllPromAndRulerRulesAction(false, {
limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined,
matcher: matcherList,
state: stateList,
})
);
}
}, [props.options.groupMode, limitInstances, dispatch, matcherList, stateList]);
useEffect(() => {
//we need promRules and rulerRules for getting the uid when creating the alert link in panel in case of being a rulerRule.
dispatch(fetchAllPromAndRulerRulesAction());
const sub = dashboard?.events.subscribe(TimeRangeUpdatedEvent, () => dispatch(fetchAllPromAndRulerRulesAction()));
dispatch(
fetchAllPromAndRulerRulesAction(false, {
limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined,
matcher: matcherList,
state: stateList,
})
);
const sub = dashboard?.events.subscribe(TimeRangeUpdatedEvent, () =>
dispatch(
fetchAllPromAndRulerRulesAction(false, {
limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined,
matcher: matcherList,
state: stateList,
})
)
);
return () => {
sub?.unsubscribe();
};
}, [dispatch, dashboard]);
}, [dispatch, dashboard, matcherList, stateList, toggleLimit, limitInstances]);
const handleInstancesLimit = (limit: boolean) => {
if (limit) {
dispatch(
fetchAllPromAndRulerRulesAction(false, {
limitAlerts: INSTANCES_DISPLAY_LIMIT,
matcher: matcherList,
state: stateList,
})
);
toggleLimit(true);
} else {
dispatch(
fetchAllPromAndRulerRulesAction(false, {
matcher: matcherList,
state: stateList,
})
);
toggleLimit(false);
}
};
const { prom, ruler } = useUnifiedAlertingSelector((state) => ({
prom: state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState,
@ -106,13 +180,6 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
);
}
const { options, replaceVariables } = props;
const parsedOptions: UnifiedAlertListOptions = {
...props.options,
alertName: replaceVariables(options.alertName),
alertInstanceLabelFilter: replaceVariables(options.alertInstanceLabelFilter),
};
return (
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
<div className={styles.container}>
@ -134,7 +201,12 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
<GroupedModeView rules={rules} options={parsedOptions} />
)}
{props.options.viewMode === ViewMode.List && props.options.groupMode === GroupMode.Default && haveResults && (
<UngroupedModeView rules={rules} options={parsedOptions} />
<UngroupedModeView
rules={rules}
options={parsedOptions}
handleInstancesLimit={handleInstancesLimit}
limitInstances={limitInstances}
/>
)}
</section>
</div>

View File

@ -1,10 +1,10 @@
import { render } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { byRole, byText } from 'testing-library-selector';
import { getDefaultTimeRange, LoadingState, PanelProps, FieldConfigSource } from '@grafana/data';
import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps } from '@grafana/data';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { DashboardSrv, setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@ -19,7 +19,7 @@ import {
import { GRAFANA_RULES_SOURCE_NAME } from '../../../features/alerting/unified/utils/datasource';
import { UnifiedAlertList } from './UnifiedAlertList';
import { UnifiedAlertListOptions, SortOrder, GroupMode, ViewMode } from './types';
import { GroupMode, SortOrder, UnifiedAlertListOptions, ViewMode } from './types';
import * as utils from './util';
jest.mock('app/features/alerting/unified/api/alertmanager');
@ -88,6 +88,8 @@ const renderPanel = (options: Partial<UnifiedAlertListOptions> = defaultOptions)
mockPromAlertingRule({
name: 'rule1',
alerts: [mockPromAlert({ labels: { severity: 'critical' } })],
totals: { alerting: 1 },
totalsFiltered: { alerting: 1 },
}),
],
}),
@ -112,6 +114,7 @@ const renderPanel = (options: Partial<UnifiedAlertListOptions> = defaultOptions)
describe('UnifiedAlertList', () => {
it('subscribes to the dashboard refresh interval', async () => {
jest.spyOn(defaultProps, 'replaceVariables').mockReturnValue('severity=critical');
await renderPanel();
expect(dashboard.events.subscribe).toHaveBeenCalledTimes(1);
expect(dashboard.events.subscribe.mock.calls[0][0]).toEqual(TimeRangeUpdatedEvent);
@ -125,7 +128,7 @@ describe('UnifiedAlertList', () => {
const user = userEvent.setup();
renderPanel({
await renderPanel({
alertInstanceLabelFilter: '$label',
dashboardAlerts: false,
alertName: '',
@ -135,6 +138,10 @@ describe('UnifiedAlertList', () => {
expect(byText('rule1').get()).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('1 instance')).toBeInTheDocument();
});
const expandElement = byText('1 instance').get();
await user.click(expandElement);

View File

@ -42,7 +42,7 @@ export interface AlertListOptions {
folderId: number;
}
interface StateFilter {
export interface StateFilter {
firing: boolean;
pending: boolean;
inactive?: boolean; // backwards compat

View File

@ -17,7 +17,8 @@ import {
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertingRule, CombinedRuleWithLocation } from '../../../../types/unified-alerting';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../../features/alerting/unified/utils/datasource';
import { AlertingRule, AlertInstanceTotalState, CombinedRuleWithLocation } from '../../../../types/unified-alerting';
import { AlertInstances } from '../AlertInstances';
import { getStyles } from '../UnifiedAlertList';
import { UnifiedAlertListOptions } from '../types';
@ -25,9 +26,17 @@ import { UnifiedAlertListOptions } from '../types';
type Props = {
rules: CombinedRuleWithLocation[];
options: UnifiedAlertListOptions;
handleInstancesLimit?: (limit: boolean) => void;
limitInstances: boolean;
};
const UngroupedModeView = ({ rules, options }: Props) => {
function getGrafanaInstancesTotal(totals: Partial<Record<AlertInstanceTotalState, number>>) {
return Object.values(totals)
.filter((total) => total !== undefined)
.reduce((total, currentTotal) => total + currentTotal, 0);
}
const UngroupedModeView = ({ rules, options, handleInstancesLimit, limitInstances }: Props) => {
const styles = useStyles2(getStyles);
const stateStyle = useStyles2(getStateTagStyles);
const { href: returnTo } = useLocation();
@ -46,6 +55,15 @@ const UngroupedModeView = ({ rules, options }: Props) => {
const indentifier = fromCombinedRule(ruleWithLocation.dataSourceName, ruleWithLocation);
const strIndentifier = stringifyIdentifier(indentifier);
const grafanaInstancesTotal =
ruleWithLocation.dataSourceName === GRAFANA_RULES_SOURCE_NAME
? getGrafanaInstancesTotal(ruleWithLocation.instanceTotals)
: undefined;
const grafanaFilteredInstancesTotal =
ruleWithLocation.dataSourceName === GRAFANA_RULES_SOURCE_NAME
? getGrafanaInstancesTotal(ruleWithLocation.filteredInstanceTotals)
: undefined;
const href = createUrl(
`/alerting/${encodeURIComponent(dataSourceName)}/${encodeURIComponent(strIndentifier)}/view`,
{ returnTo: returnTo ?? '' }
@ -96,7 +114,14 @@ const UngroupedModeView = ({ rules, options }: Props) => {
)}
</div>
</div>
<AlertInstances alerts={alertingRule.alerts ?? []} options={options} />
<AlertInstances
alerts={alertingRule.alerts ?? []}
options={options}
grafanaTotalInstances={grafanaInstancesTotal}
grafanaFilteredInstancesTotal={grafanaFilteredInstancesTotal}
handleInstancesLimit={handleInstancesLimit}
limitInstances={limitInstances}
/>
</div>
</li>
);

View File

@ -2,6 +2,8 @@
import { DataQuery, RelativeTimeRange } from '@grafana/data';
import { AlertGroupTotals } from './unified-alerting';
export type Labels = Record<string, string>;
export type Annotations = Record<string, string>;
@ -153,7 +155,10 @@ export interface PromResponse<T> {
warnings?: string[];
}
export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[] }>;
export type PromRulesResponse = PromResponse<{
groups: PromRuleGroupDTO[];
totals?: AlertGroupTotals;
}>;
// Ruler rule DTOs
interface RulerRuleBaseDTO {

View File

@ -45,6 +45,9 @@ export interface AlertingRule extends RuleBase {
};
state: PromAlertingRuleState;
type: PromRuleType.Alerting;
totals?: Partial<Record<Lowercase<GrafanaAlertState>, number>>;
totalsFiltered?: Partial<Record<Lowercase<GrafanaAlertState>, number>>;
activeAt?: string; // ISO timestamp
}
export interface RecordingRule extends RuleBase {
@ -59,10 +62,16 @@ export type Rule = AlertingRule | RecordingRule;
export type BaseRuleGroup = { name: string };
type TotalsWithoutAlerting = Exclude<AlertInstanceTotalState, AlertInstanceTotalState.Alerting>;
enum FiringTotal {
Firing = 'firing',
}
export interface RuleGroup {
name: string;
interval: number;
rules: Rule[];
// totals only exist for Grafana Managed rules
totals?: Partial<Record<TotalsWithoutAlerting | FiringTotal, number>>;
}
export interface RuleNamespace {
@ -89,13 +98,30 @@ export interface CombinedRule {
rulerRule?: RulerRuleDTO;
group: CombinedRuleGroup;
namespace: CombinedRuleNamespace;
instanceTotals: AlertInstanceTotals;
filteredInstanceTotals: AlertInstanceTotals;
}
// export type AlertInstanceState = PromAlertingRuleState | 'nodata' | 'error';
export enum AlertInstanceTotalState {
Alerting = 'alerting',
Pending = 'pending',
Normal = 'inactive',
NoData = 'nodata',
Error = 'error',
}
export type AlertInstanceTotals = Partial<Record<AlertInstanceTotalState, number>>;
// AlertGroupTotals also contain the amount of recording and paused rules
export type AlertGroupTotals = Partial<Record<AlertInstanceTotalState | 'paused' | 'recording', number>>;
export interface CombinedRuleGroup {
name: string;
interval?: string;
source_tenants?: string[];
rules: CombinedRule[];
totals: AlertGroupTotals;
}
export interface CombinedRuleNamespace {