mirror of
https://github.com/grafana/grafana.git
synced 2025-02-14 01:23:32 -06:00
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:
parent
44035ecbb2
commit
64ee42d01e
@ -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
|
||||
|
38
public/app/features/alerting/unified/api/prometheus.test.ts
Normal file
38
public/app/features/alerting/unified/api/prometheus.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
@ -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%;
|
||||
`,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -42,7 +42,7 @@ export interface AlertListOptions {
|
||||
folderId: number;
|
||||
}
|
||||
|
||||
interface StateFilter {
|
||||
export interface StateFilter {
|
||||
firing: boolean;
|
||||
pending: boolean;
|
||||
inactive?: boolean; // backwards compat
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user