mirror of
https://github.com/grafana/grafana.git
synced 2025-01-17 04:02:50 -06:00
Alerting: Filter rules list (#32818)
This commit is contained in:
parent
ffe90b7abf
commit
345d9f93fe
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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([]);
|
||||
|
@ -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};
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
@ -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: {} } } };
|
||||
}
|
||||
}
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -91,3 +91,9 @@ export interface RuleLocation {
|
||||
groupName: string;
|
||||
ruleHash: number;
|
||||
}
|
||||
|
||||
export interface RuleFilterState {
|
||||
queryString?: string;
|
||||
dataSource?: string;
|
||||
alertState?: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user