Alerting: Filter rules list (#32818)

This commit is contained in:
Nathan Rodman 2021-04-15 04:53:40 -07:00 committed by GitHub
parent ffe90b7abf
commit 345d9f93fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 312 additions and 17 deletions

View File

@ -24,6 +24,7 @@ export interface DataSourcePickerProps {
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
type?: string | string[];
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
@ -108,9 +109,10 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
}
getDataSourceOptions() {
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId, alerting, filter } = this.props;
const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter } = this.props;
const options = this.dataSourceSrv
.getList({
alerting,
tracing,
metrics,
dashboard,
@ -118,8 +120,8 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
variables,
annotations,
pluginId,
alerting,
filter,
type,
})
.map((ds) => ({
value: ds.name,

View File

@ -60,6 +60,9 @@ export interface GetDataSourceListFilters {
/** apply a function to filter */
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
/** Only returns datasources matching the specified types (ie. Loki, Prometheus) */
type?: string | string[];
}
let singletonInstance: DataSourceSrv;

View File

@ -14,11 +14,13 @@ import {
mockPromRecordingRule,
mockPromRuleGroup,
mockPromRuleNamespace,
MockDataSourceSrv,
} from './mocks';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { SerializedError } from '@reduxjs/toolkit';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import userEvent from '@testing-library/user-event';
import { setDataSourceSrv } from '@grafana/runtime';
jest.mock('./api/prometheus');
jest.mock('./utils/config');
@ -66,11 +68,16 @@ const ui = {
};
describe('RuleList', () => {
afterEach(() => jest.resetAllMocks());
afterEach(() => {
jest.resetAllMocks();
setDataSourceSrv(undefined as any);
});
it('load & show rule groups from multiple cloud data sources', async () => {
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources));
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.api.fetchRules.mockImplementation((dataSourceName: string) => {
if (dataSourceName === dataSources.prom.name) {
return Promise.resolve([
@ -145,6 +152,7 @@ describe('RuleList', () => {
it('expand rule group, rule and alert details', async () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.fetchRules.mockImplementation((dataSourceName: string) => {
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
return Promise.resolve([]);

View File

@ -7,6 +7,7 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA';
import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useFilteredRules } from './hooks/useFilteredRules';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
import {
getAllRulesSourceNames,
@ -19,6 +20,7 @@ import { ThresholdRules } from './components/rules/ThresholdRules';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
import { isRulerNotSupportedResponse } from './utils/rules';
import RulesFilter from './components/rules/RulesFilter';
export const RuleList: FC = () => {
const dispatch = useDispatch();
@ -72,8 +74,9 @@ export const RuleList: FC = () => {
const showNewAlertSplash = dispatched && !loading && !haveResults;
const combinedNamespaces = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces);
const [thresholdNamespaces, systemNamespaces] = useMemo(() => {
const sorted = combinedNamespaces
const sorted = filteredNamespaces
.map((namespace) => ({
...namespace,
groups: namespace.groups.sort((a, b) => a.name.localeCompare(b.name)),
@ -83,7 +86,7 @@ export const RuleList: FC = () => {
sorted.filter((ns) => ns.rulesSource === GRAFANA_RULES_SOURCE_NAME),
sorted.filter((ns) => isCloudRulesSource(ns.rulesSource)),
];
}, [combinedNamespaces]);
}, [filteredNamespaces]);
return (
<AlertingPageWrapper pageId="alert-list" isLoading={loading && !haveResults}>
@ -119,12 +122,16 @@ export const RuleList: FC = () => {
</InfoBox>
)}
{!showNewAlertSplash && (
<div className={styles.buttonsContainer}>
<div />
<a href="/alerting/new">
<Button icon="plus">New alert rule</Button>
</a>
</div>
<>
<RulesFilter />
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<div />
<a href="/alerting/new">
<Button icon="plus">New alert rule</Button>
</a>
</div>
</>
)}
{showNewAlertSplash && <NoRulesSplash />}
{haveResults && <ThresholdRules namespaces={thresholdNamespaces} />}
@ -134,6 +141,12 @@ export const RuleList: FC = () => {
};
const getStyles = (theme: GrafanaTheme) => ({
break: css`
width: 100%;
height: 0;
margin-bottom: ${theme.spacing.md};
border-bottom: solid 1px ${theme.colors.border2};
`,
iconError: css`
color: ${theme.palette.red};
margin-right: ${theme.spacing.md};

View File

@ -0,0 +1,122 @@
import React, { FormEvent, useState } from 'react';
import { Button, Icon, Input, Label, RadioButtonGroup, useStyles } from '@grafana/ui';
import { DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data';
import { css, cx } from '@emotion/css';
import { debounce } from 'lodash';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { getFiltersFromUrlParams } from '../../utils/misc';
import { DataSourcePicker } from '@grafana/runtime';
const RulesFilter = () => {
const [queryParams, setQueryParams] = useQueryParams();
// This key is used to force a rerender on the inputs when the filters are cleared
const [filterKey, setFilterKey] = useState<number>(Math.floor(Math.random() * 100));
const dataSourceKey = `dataSource-${filterKey}`;
const queryStringKey = `queryString-${filterKey}`;
const { dataSource, alertState, queryString } = getFiltersFromUrlParams(queryParams);
const styles = useStyles(getStyles);
const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({ label: key, value }));
const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => {
setQueryParams({ dataSource: dataSourceValue.name });
};
const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
setQueryParams({ queryString: target.value || null });
}, 600);
const handleAlertStateChange = (value: string) => {
setQueryParams({ alertState: value });
};
const handleClearFiltersClick = () => {
setQueryParams({
alertState: null,
queryString: null,
dataSource: null,
});
setFilterKey(filterKey + 1);
};
const searchIcon = <Icon name={'search'} />;
return (
<div className={styles.container}>
<div className={styles.inputWidth}>
<Label>Select data source</Label>
<DataSourcePicker
key={dataSourceKey}
alerting
noDefault
current={dataSource}
onChange={handleDataSourceChange}
/>
</div>
<div className={cx(styles.flexRow, styles.spaceBetween)}>
<div className={styles.flexRow}>
<div className={styles.rowChild}>
<Label>Search by name or label</Label>
<Input
key={queryStringKey}
className={styles.inputWidth}
prefix={searchIcon}
onChange={handleQueryStringChange}
defaultValue={queryString}
/>
</div>
<div className={styles.rowChild}>
<RadioButtonGroup options={stateOptions} value={alertState} onChange={handleAlertStateChange} />
</div>
</div>
{(dataSource || alertState || queryString) && (
<div className={styles.flexRow}>
<Button fullWidth={false} icon="times" variant="secondary" onClick={handleClearFiltersClick}>
Clear filters
</Button>
</div>
)}
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
container: css`
display: flex;
flex-direction: column;
border-bottom: 1px solid ${theme.colors.border1};
padding-bottom: ${theme.spacing.sm};
& > div {
margin-bottom: ${theme.spacing.sm};
}
`,
inputWidth: css`
width: 340px;
flex-grow: 0;
`,
flexRow: css`
display: flex;
flex-direction: row;
align-items: flex-end;
`,
spaceBetween: css`
justify-content: space-between;
`,
rowChild: css`
& + & {
margin-left: ${theme.spacing.sm};
}
`,
clearButton: css`
align-self: flex-end;
`,
};
};
export default RulesFilter;

View File

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

View File

@ -87,7 +87,7 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
ns.groups.push(combinedGroup);
}
group.rules.forEach((rule) => {
(group.rules ?? []).forEach((rule) => {
const existingRule = combinedGroup!.rules.find((existingRule) => {
return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule);
});

View File

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleFilterState } from 'app/types/unified-alerting';
import { isCloudRulesSource } from '../utils/datasource';
import { isAlertingRule } from '../utils/rules';
import { getFiltersFromUrlParams } from '../utils/misc';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
const [queryParams] = useQueryParams();
const filters = getFiltersFromUrlParams(queryParams);
return useMemo(() => {
if (!filters.queryString && !filters.dataSource && !filters.alertState) {
return namespaces;
}
const filteredNamespaces = namespaces
// Filter by data source
// TODO: filter by multiple data sources for grafana-managed alerts
.filter(({ rulesSource }) =>
filters.dataSource && isCloudRulesSource(rulesSource) ? rulesSource.name === filters.dataSource : true
)
// If a namespace and group have rules that match the rules filters then keep them.
.reduce(reduceNamespaces(filters), [] as CombinedRuleNamespace[]);
return filteredNamespaces;
}, [namespaces, filters]);
};
const reduceNamespaces = (filters: RuleFilterState) => {
return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => {
const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]);
if (groups.length) {
namespaceAcc.push({
...namespace,
groups,
});
}
return namespaceAcc;
};
};
// Reduces groups to only groups that have rules matching the filters
const reduceGroups = (filters: RuleFilterState) => {
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
const rules = group.rules.filter((rule) => {
let shouldKeep = true;
// Query strings can match alert name, label keys, and label values
if (filters.queryString) {
const normalizedQueryString = filters.queryString.toLocaleLowerCase();
const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString);
const doLabelsContainQueryString = Object.entries(rule.labels || {}).some(
([key, value]) =>
key.toLocaleLowerCase().includes(normalizedQueryString) ||
value.toLocaleLowerCase().includes(normalizedQueryString)
);
shouldKeep = doesNameContainsQueryString || doLabelsContainQueryString;
}
if (filters.alertState) {
const matchesAlertState = Boolean(
rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filters.alertState
);
shouldKeep = shouldKeep && matchesAlertState;
}
return shouldKeep;
});
// Add rules to the group that match the rule list filters
if (rules.length) {
groupAcc.push({
...group,
rules,
});
}
return groupAcc;
};
};

View File

@ -1,6 +1,8 @@
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta, ScopedVars } from '@grafana/data';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime';
let nextDataSourceId = 1;
@ -91,3 +93,47 @@ export const mockPromRuleNamespace = (partial: Partial<RuleNamespace> = {}): Rul
...partial,
};
};
export class MockDataSourceSrv implements DataSourceSrv {
// @ts-ignore
private settingsMapByName: Record<string, DataSourceInstanceSettings> = {};
private settingsMapByUid: Record<string, DataSourceInstanceSettings> = {};
private settingsMapById: Record<string, DataSourceInstanceSettings> = {};
// @ts-ignore
private templateSrv = {
getVariables: () => [],
replace: (name: any) => name,
};
constructor(datasources: Record<string, DataSourceInstanceSettings>) {
this.settingsMapByName = Object.values(datasources).reduce<Record<string, DataSourceInstanceSettings>>(
(acc, ds) => {
acc[ds.name] = ds;
return acc;
},
{}
);
for (const dsSettings of Object.values(this.settingsMapByName)) {
this.settingsMapByUid[dsSettings.uid] = dsSettings;
this.settingsMapById[dsSettings.id] = dsSettings;
}
}
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
return Promise.reject(new Error('not implemented'));
}
/**
* Get a list of data sources
*/
getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[] {
return DatasourceSrv.prototype.getList.call(this, filters);
}
/**
* Get settings and plugin metadata by name or uid
*/
getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined {
return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) || { meta: { info: { logos: {} } } };
}
}

View File

@ -1,5 +1,6 @@
import { config } from '@grafana/runtime';
import { urlUtil } from '@grafana/data';
import { urlUtil, UrlQueryMap } from '@grafana/data';
import { RuleFilterState } from 'app/types/unified-alerting';
export function createExploreLink(dataSourceName: string, query: string) {
return urlUtil.renderUrl(config.appSubUrl + '/explore', {
@ -33,3 +34,11 @@ export function arrayToRecord(items: Array<{ key: string; value: string }>): Rec
return rec;
}, {});
}
export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterState => {
const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']);
const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']);
const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
return { queryString, alertState, dataSource };
};

View File

@ -173,18 +173,25 @@ export class DatasourceSrv implements DataSourceService {
if (filters.annotations && !x.meta.annotations) {
return false;
}
if (filters.alerting && !x.meta.alerting) {
return false;
}
if (filters.pluginId && x.meta.id !== filters.pluginId) {
return false;
}
if (filters.filter && !filters.filter(x)) {
return false;
}
if (filters.type && (Array.isArray(filters.type) ? !filters.type.includes(x.type) : filters.type !== x.type)) {
return false;
}
if (
!filters.all &&
x.meta.metrics !== true &&
x.meta.annotations !== true &&
x.meta.tracing !== true &&
x.meta.logs !== true
x.meta.logs !== true &&
x.meta.alerting !== true
) {
return false;
}

View File

@ -91,3 +91,9 @@ export interface RuleLocation {
groupName: string;
ruleHash: number;
}
export interface RuleFilterState {
queryString?: string;
dataSource?: string;
alertState?: string;
}