mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Alert rules search improvements (#61398)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
94dca85b30
commit
e8dd01df35
@ -3000,8 +3000,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RulesFilter.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
|
@ -498,10 +498,10 @@ describe('RuleList', () => {
|
||||
expect(groups).toHaveLength(2);
|
||||
|
||||
const filterInput = ui.rulesFilterInput.get();
|
||||
await userEvent.type(filterInput, '{{foo="bar"}');
|
||||
await userEvent.type(filterInput, 'label:foo=bar');
|
||||
|
||||
// Input is debounced so wait for it to be visible
|
||||
await waitFor(() => expect(filterInput).toHaveValue('{foo="bar"}'));
|
||||
await waitFor(() => expect(filterInput).toHaveValue('label:foo=bar'));
|
||||
// Group doesn't contain matching labels
|
||||
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
|
||||
|
||||
@ -517,17 +517,17 @@ describe('RuleList', () => {
|
||||
|
||||
// Check for different label matchers
|
||||
await userEvent.clear(filterInput);
|
||||
await userEvent.type(filterInput, '{{foo!="bar",foo!="baz"}');
|
||||
await userEvent.type(filterInput, 'label:foo!=bar label:foo!=baz');
|
||||
// Group doesn't contain matching labels
|
||||
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
|
||||
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
|
||||
|
||||
await userEvent.clear(filterInput);
|
||||
await userEvent.type(filterInput, '{{foo=~"b.+"}');
|
||||
await userEvent.type(filterInput, 'label:"foo=~b.+"');
|
||||
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2));
|
||||
|
||||
await userEvent.clear(filterInput);
|
||||
await userEvent.type(filterInput, '{{region="US"}');
|
||||
await userEvent.type(filterInput, 'label:region=US');
|
||||
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
|
||||
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
|
||||
});
|
||||
|
@ -20,13 +20,12 @@ import { RuleListStateView } from './components/rules/RuleListStateView';
|
||||
import { RuleStats } from './components/rules/RuleStats';
|
||||
import RulesFilter from './components/rules/RulesFilter';
|
||||
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
|
||||
import { useFilteredRules } from './hooks/useFilteredRules';
|
||||
import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAllPromAndRulerRulesAction } from './state/actions';
|
||||
import { useRulesAccess } from './utils/accessControlHooks';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
|
||||
import { getAllRulesSourceNames } from './utils/datasource';
|
||||
import { getFiltersFromUrlParams } from './utils/misc';
|
||||
|
||||
const VIEWS = {
|
||||
groups: RuleListGroupView,
|
||||
@ -42,8 +41,7 @@ const RuleList = withErrorBoundary(
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const [queryParams] = useQueryParams();
|
||||
const filters = getFiltersFromUrlParams(queryParams);
|
||||
const filtersActive = Object.values(filters).some((filter) => filter !== undefined);
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess();
|
||||
|
||||
@ -83,19 +81,20 @@ const RuleList = withErrorBoundary(
|
||||
const hasNoAlertRulesCreatedYet = allPromLoaded && allPromEmpty && promRequests.length > 0;
|
||||
|
||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces);
|
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper pageId="alert-list" isLoading={false}>
|
||||
<RuleListErrors />
|
||||
<RulesFilter />
|
||||
<RulesFilter onFilterCleared={() => setExpandAll(false)} />
|
||||
{!hasNoAlertRulesCreatedYet && (
|
||||
<>
|
||||
<div className={styles.break} />
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.statsContainer}>
|
||||
{view === 'groups' && filtersActive && (
|
||||
{view === 'groups' && hasActiveFilters && (
|
||||
<Button
|
||||
className={styles.expandAllButton}
|
||||
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
|
||||
|
@ -31,15 +31,15 @@ function Tokenize({ input, delimiter = ['{{', '}}'] }: TokenizerProps) {
|
||||
|
||||
const output: React.ReactElement[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
lines.forEach((line, lineIndex) => {
|
||||
const matches = Array.from(line.matchAll(regex));
|
||||
|
||||
matches.forEach((match, index) => {
|
||||
matches.forEach((match, matchIndex) => {
|
||||
const before = match.groups?.before;
|
||||
const token = match.groups?.token?.trim();
|
||||
|
||||
if (before) {
|
||||
output.push(<span key={`${index}-before`}>{before}</span>);
|
||||
output.push(<span key={`${lineIndex}-${matchIndex}-before`}>{before}</span>);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
@ -47,11 +47,18 @@ function Tokenize({ input, delimiter = ['{{', '}}'] }: TokenizerProps) {
|
||||
const description = type === TokenType.Variable ? token : '';
|
||||
const tokenContent = `${open} ${token} ${close}`;
|
||||
|
||||
output.push(<Token key={`${index}-token`} content={tokenContent} type={type} description={description} />);
|
||||
output.push(
|
||||
<Token
|
||||
key={`${lineIndex}-${matchIndex}-token`}
|
||||
content={tokenContent}
|
||||
type={type}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
output.push(<br key={`${index}-newline`} />);
|
||||
output.push(<br key={`${lineIndex}-newline`} />);
|
||||
});
|
||||
|
||||
return <span className={styles.wrapper}>{output}</span>;
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { FormEvent, useState } from 'react';
|
||||
|
||||
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { DataSourcePicker, logInfo } from '@grafana/runtime';
|
||||
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Field, Icon, Input, Label, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { LogMessages } from '../../Analytics';
|
||||
import { getFiltersFromUrlParams } from '../../utils/misc';
|
||||
import { useRulesFilter } from '../../hooks/useFilteredRules';
|
||||
import { RuleHealth } from '../../search/rulesSearchParser';
|
||||
import { alertStateToReadable } from '../../utils/rules';
|
||||
import { HoverCard } from '../HoverCard';
|
||||
|
||||
const ViewOptions: SelectableValue[] = [
|
||||
{
|
||||
@ -42,14 +44,25 @@ const RuleTypeOptions: SelectableValue[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const RulesFilter = () => {
|
||||
const RuleHealthOptions: SelectableValue[] = [
|
||||
{ label: 'Ok', value: RuleHealth.Ok },
|
||||
{ label: 'No Data', value: RuleHealth.NoData },
|
||||
{ label: 'Error', value: RuleHealth.Error },
|
||||
];
|
||||
|
||||
interface RulesFilerProps {
|
||||
onFilterCleared?: () => void;
|
||||
}
|
||||
|
||||
const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => {
|
||||
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, ruleType } = getFiltersFromUrlParams(queryParams);
|
||||
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
|
||||
@ -58,21 +71,24 @@ const RulesFilter = () => {
|
||||
}));
|
||||
|
||||
const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => {
|
||||
setQueryParams({ dataSource: dataSourceValue.name });
|
||||
updateFilters({ ...filterState, dataSourceName: dataSourceValue.name });
|
||||
setFilterKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const clearDataSource = () => {
|
||||
setQueryParams({ dataSource: null });
|
||||
updateFilters({ ...filterState, dataSourceName: undefined });
|
||||
setFilterKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setQueryParams({ queryString: target.value || null });
|
||||
setSearchQuery(target.value);
|
||||
}, 600);
|
||||
|
||||
const handleAlertStateChange = (value: string) => {
|
||||
const handleAlertStateChange = (value: PromAlertingRuleState) => {
|
||||
logInfo(LogMessages.clickingAlertStateFilters);
|
||||
setQueryParams({ alertState: value });
|
||||
updateFilters({ ...filterState, ruleState: value });
|
||||
setFilterKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleViewChange = (view: string) => {
|
||||
@ -80,100 +96,97 @@ const RulesFilter = () => {
|
||||
};
|
||||
|
||||
const handleRuleTypeChange = (ruleType: PromRuleType) => {
|
||||
setQueryParams({ ruleType });
|
||||
updateFilters({ ...filterState, ruleType });
|
||||
setFilterKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleRuleHealthChange = (ruleHealth: RuleHealth) => {
|
||||
updateFilters({ ...filterState, ruleHealth });
|
||||
setFilterKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleClearFiltersClick = () => {
|
||||
setQueryParams({
|
||||
alertState: null,
|
||||
queryString: null,
|
||||
dataSource: null,
|
||||
ruleType: null,
|
||||
});
|
||||
setSearchQuery(undefined);
|
||||
onFilterCleared();
|
||||
|
||||
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
||||
};
|
||||
|
||||
const searchIcon = <Icon name={'search'} />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Field className={styles.inputWidth} label="Search by data source">
|
||||
<DataSourcePicker
|
||||
key={dataSourceKey}
|
||||
alerting
|
||||
noDefault
|
||||
placeholder="All data sources"
|
||||
current={dataSource}
|
||||
onChange={handleDataSourceChange}
|
||||
onClear={clearDataSource}
|
||||
/>
|
||||
</Field>
|
||||
<div className={cx(styles.flexRow, styles.spaceBetween)}>
|
||||
<div className={styles.flexRow}>
|
||||
<Field
|
||||
className={styles.rowChild}
|
||||
label={
|
||||
<Label>
|
||||
<Stack gap={0.5}>
|
||||
<span>Search by label</span>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
Filter rules and alerts using label querying, ex:
|
||||
<code>{`{severity="critical", instance=~"cluster-us-.+"}`}</code>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
key={queryStringKey}
|
||||
className={styles.inputWidth}
|
||||
prefix={searchIcon}
|
||||
onChange={handleQueryStringChange}
|
||||
defaultValue={queryString}
|
||||
placeholder="Search"
|
||||
data-testid="search-query-input"
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Field className={styles.dsPickerContainer} label="Search by data source">
|
||||
<DataSourcePicker
|
||||
key={dataSourceKey}
|
||||
alerting
|
||||
noDefault
|
||||
placeholder="All data sources"
|
||||
current={filterState.dataSourceName}
|
||||
onChange={handleDataSourceChange}
|
||||
onClear={clearDataSource}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.rowChild}>
|
||||
<div>
|
||||
<Label>State</Label>
|
||||
<RadioButtonGroup options={stateOptions} value={alertState} onChange={handleAlertStateChange} />
|
||||
<RadioButtonGroup options={stateOptions} value={filterState.ruleState} onChange={handleAlertStateChange} />
|
||||
</div>
|
||||
<div className={styles.rowChild}>
|
||||
<div>
|
||||
<Label>Rule type</Label>
|
||||
<RadioButtonGroup options={RuleTypeOptions} value={filterState.ruleType} onChange={handleRuleTypeChange} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Health</Label>
|
||||
<RadioButtonGroup
|
||||
options={RuleTypeOptions}
|
||||
value={ruleType as PromRuleType}
|
||||
onChange={handleRuleTypeChange}
|
||||
options={RuleHealthOptions}
|
||||
value={filterState.ruleHealth}
|
||||
onChange={handleRuleHealthChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rowChild}>
|
||||
<Label>View as</Label>
|
||||
<RadioButtonGroup
|
||||
options={ViewOptions}
|
||||
value={String(queryParams['view'] ?? ViewOptions[0].value)}
|
||||
onChange={handleViewChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(dataSource || alertState || queryString || ruleType) && (
|
||||
<div className={styles.flexRow}>
|
||||
<Button
|
||||
className={styles.clearButton}
|
||||
fullWidth={false}
|
||||
icon="times"
|
||||
variant="secondary"
|
||||
onClick={handleClearFiltersClick}
|
||||
</Stack>
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Field
|
||||
className={styles.searchInput}
|
||||
label={
|
||||
<Label>
|
||||
<Stack gap={0.5}>
|
||||
<span>Search</span>
|
||||
<HoverCard content={<SearchQueryHelp />}>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</HoverCard>
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
key={queryStringKey}
|
||||
prefix={searchIcon}
|
||||
onChange={handleQueryStringChange}
|
||||
defaultValue={searchQuery}
|
||||
placeholder="Search"
|
||||
data-testid="search-query-input"
|
||||
/>
|
||||
</Field>
|
||||
<div>
|
||||
<Label>View as</Label>
|
||||
<RadioButtonGroup
|
||||
options={ViewOptions}
|
||||
value={String(queryParams['view'] ?? ViewOptions[0].value)}
|
||||
onChange={handleViewChange}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
{hasActiveFilters && (
|
||||
<div>
|
||||
<Button fullWidth={false} icon="times" variant="secondary" onClick={handleClearFiltersClick}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -181,32 +194,65 @@ const RulesFilter = () => {
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: ${theme.spacing(1)};
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
inputWidth: css`
|
||||
width: 340px;
|
||||
dsPickerContainer: css`
|
||||
width: 250px;
|
||||
flex-grow: 0;
|
||||
margin: 0;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
`,
|
||||
spaceBetween: css`
|
||||
justify-content: space-between;
|
||||
`,
|
||||
rowChild: css`
|
||||
margin: ${theme.spacing(0, 1, 0, 0)};
|
||||
`,
|
||||
clearButton: css`
|
||||
margin-top: ${theme.spacing(1)};
|
||||
searchInput: css`
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
function SearchQueryHelp() {
|
||||
const styles = useStyles2(helpStyles);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Search syntax allows to query alert rules by the parameters defined below.</div>
|
||||
<hr />
|
||||
<div className={styles.grid}>
|
||||
<div>Filter type</div>
|
||||
<div>Expression</div>
|
||||
<HelpRow title="Datasource" expr="datasource:mimir" />
|
||||
<HelpRow title="Folder/Namespace" expr="namespace:global" />
|
||||
<HelpRow title="Group" expr="group:cpu-usage" />
|
||||
<HelpRow title="Rule" expr='rule:"cpu 80%"' />
|
||||
<HelpRow title="Labels" expr="label:team=A label:cluster=a1" />
|
||||
<HelpRow title="State" expr="state:firing|normal|pending" />
|
||||
<HelpRow title="Type" expr="type:alerting|recording" />
|
||||
<HelpRow title="Health" expr="health:ok|nodata|error" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpRow({ title, expr }: { title: string; expr: string }) {
|
||||
const styles = useStyles2(helpStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{title}</div>
|
||||
<code className={styles.code}>{expr}</code>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const helpStyles = (theme: GrafanaTheme2) => ({
|
||||
grid: css`
|
||||
display: grid;
|
||||
grid-template-columns: max-content auto;
|
||||
gap: ${theme.spacing(1)};
|
||||
align-items: center;
|
||||
`,
|
||||
code: css`
|
||||
display: block;
|
||||
text-align: center;
|
||||
`,
|
||||
});
|
||||
|
||||
export default RulesFilter;
|
||||
|
@ -0,0 +1,163 @@
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
|
||||
import { PromAlertingRuleState } from '../../../../types/unified-alerting-dto';
|
||||
import {
|
||||
mockAlertQuery,
|
||||
mockCombinedRule,
|
||||
mockCombinedRuleGroup,
|
||||
mockCombinedRuleNamespace,
|
||||
mockDataSource,
|
||||
MockDataSourceSrv,
|
||||
mockPromAlert,
|
||||
mockPromAlertingRule,
|
||||
mockRulerGrafanaRule,
|
||||
} from '../mocks';
|
||||
import { RuleHealth } from '../search/rulesSearchParser';
|
||||
import { getFilter } from '../utils/search';
|
||||
|
||||
import { filterRules } from './useFilteredRules';
|
||||
|
||||
const dataSources = {
|
||||
prometheus: mockDataSource({ uid: 'prom-1', name: 'prometheus' }),
|
||||
loki: mockDataSource({ uid: 'loki-1', name: 'loki' }),
|
||||
};
|
||||
beforeAll(() => {
|
||||
setDataSourceSrv(new MockDataSourceSrv(dataSources));
|
||||
});
|
||||
|
||||
describe('filterRules', function () {
|
||||
it('should filter out rules by name filter', function () {
|
||||
const rules = [mockCombinedRule({ name: 'High CPU usage' }), mockCombinedRule({ name: 'Memory too low' })];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ ruleName: 'cpu' }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage');
|
||||
});
|
||||
|
||||
it('should filter out rules by evaluation group name', function () {
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [
|
||||
mockCombinedRuleGroup('Performance group', [mockCombinedRule({ name: 'High CPU usage' })]),
|
||||
mockCombinedRuleGroup('Availability group', [mockCombinedRule({ name: 'Memory too low' })]),
|
||||
],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ groupName: 'availability' }));
|
||||
|
||||
expect(filtered[0].groups).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
|
||||
});
|
||||
|
||||
it('should filter out rules by label filter', function () {
|
||||
const rules = [
|
||||
mockCombinedRule({ name: 'High CPU usage', labels: { severity: 'warning' } }),
|
||||
mockCombinedRule({ name: 'Memory too low', labels: { severity: 'critical' } }),
|
||||
];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ labels: ['severity=critical'] }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
|
||||
});
|
||||
|
||||
it('should filter out rules by alert instance labels', function () {
|
||||
const rules = [
|
||||
mockCombinedRule({
|
||||
name: 'High CPU usage',
|
||||
promRule: mockPromAlertingRule({ alerts: [mockPromAlert({ labels: { severity: 'warning' } })] }),
|
||||
}),
|
||||
mockCombinedRule({
|
||||
name: 'Memory too low',
|
||||
promRule: mockPromAlertingRule({ labels: { severity: 'critical' }, alerts: [] }),
|
||||
}),
|
||||
];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ labels: ['severity=warning'] }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage');
|
||||
});
|
||||
|
||||
it('should filter out rules by state filter', function () {
|
||||
const rules = [
|
||||
mockCombinedRule({
|
||||
name: 'High CPU usage',
|
||||
promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }),
|
||||
}),
|
||||
mockCombinedRule({
|
||||
name: 'Memory too low',
|
||||
promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }),
|
||||
}),
|
||||
];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ ruleState: PromAlertingRuleState.Firing }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
|
||||
});
|
||||
|
||||
it('should filter out rules by health filter', function () {
|
||||
const rules = [
|
||||
mockCombinedRule({
|
||||
name: 'High CPU usage',
|
||||
promRule: mockPromAlertingRule({ health: RuleHealth.Ok }),
|
||||
}),
|
||||
mockCombinedRule({
|
||||
name: 'Memory too low',
|
||||
promRule: mockPromAlertingRule({ health: RuleHealth.Error }),
|
||||
}),
|
||||
];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ ruleHealth: RuleHealth.Error }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
|
||||
});
|
||||
|
||||
it('should filter out rules by datasource', function () {
|
||||
const rules = [
|
||||
mockCombinedRule({
|
||||
name: 'High CPU usage',
|
||||
rulerRule: mockRulerGrafanaRule(undefined, {
|
||||
data: [mockAlertQuery({ datasourceUid: dataSources.prometheus.uid })],
|
||||
}),
|
||||
}),
|
||||
mockCombinedRule({
|
||||
name: 'Memory too low',
|
||||
rulerRule: mockRulerGrafanaRule(undefined, {
|
||||
data: [mockAlertQuery({ datasourceUid: dataSources.loki.uid })],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
const ns = mockCombinedRuleNamespace({
|
||||
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
|
||||
});
|
||||
|
||||
const filtered = filterRules([ns], getFilter({ dataSourceName: 'loki' }));
|
||||
|
||||
expect(filtered[0].groups[0].rules).toHaveLength(1);
|
||||
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
|
||||
});
|
||||
});
|
@ -1,35 +1,107 @@
|
||||
import { useMemo } from 'react';
|
||||
import produce from 'immer';
|
||||
import { compact, isEmpty } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace, FilterState } from 'app/types/unified-alerting';
|
||||
import { PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager';
|
||||
import { getSearchFilterFromQuery, RulesFilter, applySearchFilterToQuery } from '../search/rulesSearchParser';
|
||||
import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers } from '../utils/alertmanager';
|
||||
import { isCloudRulesSource } from '../utils/datasource';
|
||||
import { getFiltersFromUrlParams } from '../utils/misc';
|
||||
import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
|
||||
import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules';
|
||||
|
||||
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
|
||||
const [queryParams] = useQueryParams();
|
||||
const filters = getFiltersFromUrlParams(queryParams);
|
||||
import { useURLSearchParams } from './useURLSearchParams';
|
||||
|
||||
return useMemo(() => {
|
||||
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]);
|
||||
export function useRulesFilter() {
|
||||
const [queryParams, updateQueryParams] = useURLSearchParams();
|
||||
const searchQuery = queryParams.get('search') ?? '';
|
||||
|
||||
const filterState = getSearchFilterFromQuery(searchQuery);
|
||||
const hasActiveFilters = Object.values(filterState).some((filter) => !isEmpty(filter));
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(newFilter: RulesFilter) => {
|
||||
const newSearchQuery = applySearchFilterToQuery(searchQuery, newFilter);
|
||||
updateQueryParams({ search: newSearchQuery });
|
||||
},
|
||||
[searchQuery, updateQueryParams]
|
||||
);
|
||||
|
||||
const setSearchQuery = useCallback(
|
||||
(newSearchQuery: string | undefined) => {
|
||||
updateQueryParams({ search: newSearchQuery });
|
||||
},
|
||||
[updateQueryParams]
|
||||
);
|
||||
|
||||
// Handle legacy filters
|
||||
useEffect(() => {
|
||||
const legacyFilters = {
|
||||
dataSource: queryParams.get('dataSource') ?? undefined,
|
||||
alertState: queryParams.get('alertState') ?? undefined,
|
||||
ruleType: queryParams.get('ruleType') ?? undefined,
|
||||
labels: parseMatchers(queryParams.get('queryString') ?? '').map(matcherToMatcherField),
|
||||
};
|
||||
|
||||
const hasLegacyFilters = Object.values(legacyFilters).some((legacyFilter) => !isEmpty(legacyFilter));
|
||||
if (hasLegacyFilters) {
|
||||
updateQueryParams({ dataSource: undefined, alertState: undefined, ruleType: undefined, queryString: undefined });
|
||||
// Existing query filters takes precedence over legacy ones
|
||||
updateFilters(
|
||||
produce(filterState, (draft) => {
|
||||
draft.dataSourceName ??= legacyFilters.dataSource;
|
||||
if (legacyFilters.alertState && isPromAlertingRuleState(legacyFilters.alertState)) {
|
||||
draft.ruleState ??= legacyFilters.alertState;
|
||||
}
|
||||
if (legacyFilters.ruleType && isPromRuleType(legacyFilters.ruleType)) {
|
||||
draft.ruleType ??= legacyFilters.ruleType;
|
||||
}
|
||||
if (draft.labels.length === 0 && legacyFilters.labels.length > 0) {
|
||||
const legacyLabelsAsStrings = legacyFilters.labels.map(
|
||||
({ name, operator, value }) => `${name}${operator}${value}`
|
||||
);
|
||||
draft.labels.push(...legacyLabelsAsStrings);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [queryParams, updateFilters, filterState, updateQueryParams]);
|
||||
|
||||
return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters };
|
||||
}
|
||||
|
||||
export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => {
|
||||
return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]);
|
||||
};
|
||||
|
||||
const reduceNamespaces = (filters: FilterState) => {
|
||||
export const filterRules = (
|
||||
namespaces: CombinedRuleNamespace[],
|
||||
filterState: RulesFilter = { labels: [], freeFormWords: [] }
|
||||
): CombinedRuleNamespace[] => {
|
||||
return (
|
||||
namespaces
|
||||
.filter((ns) =>
|
||||
filterState.namespace ? ns.name.toLowerCase().includes(filterState.namespace.toLowerCase()) : true
|
||||
)
|
||||
.filter(({ rulesSource }) =>
|
||||
filterState.dataSourceName && isCloudRulesSource(rulesSource)
|
||||
? rulesSource.name === filterState.dataSourceName
|
||||
: true
|
||||
)
|
||||
// If a namespace and group have rules that match the rules filters then keep them.
|
||||
.reduce(reduceNamespaces(filterState), [] as CombinedRuleNamespace[])
|
||||
);
|
||||
};
|
||||
|
||||
const reduceNamespaces = (filterStateFilters: RulesFilter) => {
|
||||
return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => {
|
||||
const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]);
|
||||
const groups = namespace.groups
|
||||
.filter((g) =>
|
||||
filterStateFilters.groupName ? g.name.toLowerCase().includes(filterStateFilters.groupName.toLowerCase()) : true
|
||||
)
|
||||
.reduce(reduceGroups(filterStateFilters), [] as CombinedRuleGroup[]);
|
||||
|
||||
if (groups.length) {
|
||||
namespaceAcc.push({
|
||||
@ -43,35 +115,56 @@ const reduceNamespaces = (filters: FilterState) => {
|
||||
};
|
||||
|
||||
// Reduces groups to only groups that have rules matching the filters
|
||||
const reduceGroups = (filters: FilterState) => {
|
||||
const reduceGroups = (filterState: RulesFilter) => {
|
||||
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
|
||||
const rules = group.rules.filter((rule) => {
|
||||
if (filters.ruleType && filters.ruleType !== rule.promRule?.type) {
|
||||
if (filterState.ruleType && filterState.ruleType !== rule.promRule?.type) {
|
||||
return false;
|
||||
}
|
||||
if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) {
|
||||
return false;
|
||||
}
|
||||
// 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 matchers = parseMatchers(filters.queryString);
|
||||
|
||||
const doRuleLabelsMatchQuery = labelsMatchMatchers(rule.labels, matchers);
|
||||
const doesNotQueryDs = isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filterState);
|
||||
if (filterState.dataSourceName && doesNotQueryDs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleNameLc = rule.name?.toLocaleLowerCase();
|
||||
// Free Form Query is used to filter by rule name
|
||||
if (
|
||||
filterState.freeFormWords.length > 0 &&
|
||||
!filterState.freeFormWords.every((w) => ruleNameLc.includes(w.toLocaleLowerCase()))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.ruleName && !rule.name?.toLocaleLowerCase().includes(filterState.ruleName.toLocaleLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.ruleHealth && rule.promRule) {
|
||||
const ruleHealth = getRuleHealth(rule.promRule.health);
|
||||
return filterState.ruleHealth === ruleHealth;
|
||||
}
|
||||
|
||||
// Query strings can match alert name, label keys, and label values
|
||||
if (filterState.labels.length > 0) {
|
||||
// const matchers = parseMatchers(filters.queryString);
|
||||
const matchers = compact(filterState.labels.map(looseParseMatcher));
|
||||
|
||||
const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels, matchers);
|
||||
const doAlertsContainMatchingLabels =
|
||||
matchers.length > 0 &&
|
||||
rule.promRule &&
|
||||
rule.promRule.type === PromRuleType.Alerting &&
|
||||
rule.promRule.alerts &&
|
||||
rule.promRule.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers));
|
||||
|
||||
if (!(doesNameContainsQueryString || doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) {
|
||||
if (!(doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
filters.alertState &&
|
||||
!(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filters.alertState)
|
||||
filterState.ruleState &&
|
||||
!(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filterState.ruleState)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@ -88,8 +181,17 @@ const reduceGroups = (filters: FilterState) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterState): boolean => {
|
||||
if (!filter.dataSource) {
|
||||
function looseParseMatcher(matcherQuery: string): Matcher | undefined {
|
||||
try {
|
||||
return parseMatcher(matcherQuery);
|
||||
} catch {
|
||||
// Try to createa a matcher than matches all values for a given key
|
||||
return { name: matcherQuery, value: '', isRegex: true, isEqual: true };
|
||||
}
|
||||
}
|
||||
|
||||
const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => {
|
||||
if (!filterState.dataSourceName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -98,6 +200,6 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterStat
|
||||
return false;
|
||||
}
|
||||
const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
||||
return ds?.name === filter.dataSource;
|
||||
return ds?.name === filterState.dataSourceName;
|
||||
});
|
||||
};
|
||||
|
@ -23,8 +23,18 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction, FolderDTO, StoreState } from 'app/types';
|
||||
import { Alert, AlertingRule, CombinedRule, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import {
|
||||
Alert,
|
||||
AlertingRule,
|
||||
CombinedRule,
|
||||
CombinedRuleGroup,
|
||||
CombinedRuleNamespace,
|
||||
RecordingRule,
|
||||
RuleGroup,
|
||||
RuleNamespace,
|
||||
} from 'app/types/unified-alerting';
|
||||
import {
|
||||
AlertQuery,
|
||||
GrafanaAlertStateDecision,
|
||||
GrafanaRuleDefinition,
|
||||
PromAlertingRuleState,
|
||||
@ -517,6 +527,29 @@ export function mockStore(recipe: (state: StoreState) => void) {
|
||||
return configureStore(produce(defaultState, recipe));
|
||||
}
|
||||
|
||||
export function mockAlertQuery(query: Partial<AlertQuery>): AlertQuery {
|
||||
return {
|
||||
datasourceUid: '--uid--',
|
||||
refId: 'A',
|
||||
queryType: '',
|
||||
model: { refId: 'A' },
|
||||
...query,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockCombinedRuleGroup(name: string, rules: CombinedRule[]): CombinedRuleGroup {
|
||||
return { name, rules };
|
||||
}
|
||||
|
||||
export function mockCombinedRuleNamespace(namespace: Partial<CombinedRuleNamespace>): CombinedRuleNamespace {
|
||||
return {
|
||||
name: 'Grafana',
|
||||
groups: [],
|
||||
rulesSource: 'grafana',
|
||||
...namespace,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGrafanaRule(override?: Partial<CombinedRule>) {
|
||||
return mockCombinedRule({
|
||||
namespace: {
|
||||
|
27
public/app/features/alerting/unified/search/README.md
Normal file
27
public/app/features/alerting/unified/search/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Alerting search syntax
|
||||
|
||||
## Lezer grammar
|
||||
|
||||
Alerting uses the [Lezer](https://lezer.codemirror.net/) parser system to create a search syntax grammar.
|
||||
|
||||
File [search.grammar](search.grammar) describes the search grammar.
|
||||
|
||||
`@lezer/generator` package is used to generate [search.js](search.js) and [search.terms.js](search.terms.js) files which include a JS grammar parser.
|
||||
|
||||
## Changing the grammar
|
||||
|
||||
After making changes in the `search.grammar` file, a new version of the parser needs to be generated.
|
||||
To do that, the following command needs to be run in the `public/app/features/alerting/unified/search` directory
|
||||
|
||||
```sh
|
||||
yarn dlx @lezer/generator search.grammar -o search.js
|
||||
```
|
||||
|
||||
The command will re-create [search.js](search.js) and [search.terms.js](search.terms.js) files which are the files containing grammar parser.
|
||||
|
||||
## Extensibility
|
||||
|
||||
The `search.grammar` uses the [dialects feature](https://lezer.codemirror.net/docs/guide/#dialects) of Lezer to enable parsing of each filter term separately.
|
||||
|
||||
This will allow us to have a single grammar file for handling filter expressions for all of our filters (e.g. Rules, Silences, Notification policies).
|
||||
Then we can configure the required set of filters dynamically in the JS code using the parser.
|
@ -0,0 +1,183 @@
|
||||
import { PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto';
|
||||
import { getFilter } from '../utils/search';
|
||||
|
||||
import { applySearchFilterToQuery, getSearchFilterFromQuery, RuleHealth } from './rulesSearchParser';
|
||||
|
||||
describe('Alert rules searchParser', () => {
|
||||
describe('getSearchFilterFromQuery', () => {
|
||||
it.each(['datasource:prometheus'])('should parse data source filter from "%s" query', (query) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.dataSourceName).toBe('prometheus');
|
||||
});
|
||||
|
||||
it.each(['namespace:integrations-node'])('should parse namespace filter from "%s" query', (query) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.namespace).toBe('integrations-node');
|
||||
});
|
||||
|
||||
it.each(['label:team label:region=emea'])('should parse label filter from "%s" query', (query) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.labels).toHaveLength(2);
|
||||
expect(filter.labels).toContain('team');
|
||||
expect(filter.labels).toContain('region=emea');
|
||||
});
|
||||
|
||||
it.each(['group:cpu-utilization'])('should parse group filter from "%s" query', (query) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.groupName).toBe('cpu-utilization');
|
||||
});
|
||||
|
||||
it.each(['rule:cpu-80%-alert'])('should parse rule name filter from "%s" query', (query) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.ruleName).toBe('cpu-80%-alert');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ query: 'state:firing', expectedFilter: PromAlertingRuleState.Firing },
|
||||
{ query: 'state:inactive', expectedFilter: PromAlertingRuleState.Inactive },
|
||||
{ query: 'state:pending', expectedFilter: PromAlertingRuleState.Pending },
|
||||
])('should parse $expectedFilter rule state filter from "$query" query', ({ query, expectedFilter }) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.ruleState).toBe(expectedFilter);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ query: 'type:alerting', expectedFilter: PromRuleType.Alerting },
|
||||
{ query: 'type:recording', expectedFilter: PromRuleType.Recording },
|
||||
])('should parse $expectedFilter rule type filter from "$query" input', ({ query, expectedFilter }) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.ruleType).toBe(expectedFilter);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ query: 'health:ok', expectedFilter: RuleHealth.Ok },
|
||||
{ query: 'health:nodata', expectedFilter: RuleHealth.NoData },
|
||||
{ query: 'health:error', expectedFilter: RuleHealth.Error },
|
||||
])('should parse RuleHealth $expectedFilter filter from "$query" query', ({ query, expectedFilter }) => {
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
expect(filter.ruleHealth).toBe(expectedFilter);
|
||||
});
|
||||
|
||||
it('should parse non-filtering words as free form query', () => {
|
||||
const filter = getSearchFilterFromQuery('cpu usage rule');
|
||||
expect(filter.freeFormWords).toHaveLength(3);
|
||||
expect(filter.freeFormWords).toContain('cpu');
|
||||
expect(filter.freeFormWords).toContain('usage');
|
||||
expect(filter.freeFormWords).toContain('rule');
|
||||
});
|
||||
|
||||
it('should parse free words with quotes', () => {
|
||||
const query = '"hello world" hello world';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.freeFormWords).toEqual(['hello world', 'hello', 'world']);
|
||||
});
|
||||
|
||||
it('should parse filter values with whitespaces when in quotes', () => {
|
||||
const query =
|
||||
'datasource:"prom dev" namespace:"node one" label:"team=frontend us" group:"cpu alerts" rule:"cpu failure"';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.dataSourceName).toBe('prom dev');
|
||||
expect(filter.namespace).toBe('node one');
|
||||
expect(filter.labels).toContain('team=frontend us');
|
||||
expect(filter.groupName).toContain('cpu alerts');
|
||||
expect(filter.ruleName).toContain('cpu failure');
|
||||
});
|
||||
|
||||
it('should parse filter values with special characters', () => {
|
||||
const query =
|
||||
'datasource:prom::dev/linux>>; namespace:"[{node}] (#20+)" label:_region=apac|emea\\nasa group:$20.00%$ rule:"cpu!! & memory.,?"';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.dataSourceName).toBe('prom::dev/linux>>;');
|
||||
expect(filter.namespace).toBe('[{node}] (#20+)');
|
||||
expect(filter.labels).toContain('_region=apac|emea\\nasa');
|
||||
expect(filter.groupName).toContain('$20.00%$');
|
||||
expect(filter.ruleName).toContain('cpu!! & memory.,?');
|
||||
});
|
||||
|
||||
it('should parse non-filter terms with colon as free form words', () => {
|
||||
const query = 'cpu:high-utilization memory:overload';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.freeFormWords).toContain('cpu:high-utilization');
|
||||
expect(filter.freeFormWords).toContain('memory:overload');
|
||||
});
|
||||
|
||||
it('should parse mixed free form words and filters', () => {
|
||||
const query = 'datasource:prometheus utilization label:team cpu';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.dataSourceName).toBe('prometheus');
|
||||
expect(filter.labels).toContain('team');
|
||||
expect(filter.freeFormWords).toContain('utilization');
|
||||
expect(filter.freeFormWords).toContain('cpu');
|
||||
});
|
||||
|
||||
it('should parse labels containing matchers', () => {
|
||||
const query = 'label:region!=US label:"team=~fe.*devs" label:cluster!~ba.+';
|
||||
const filter = getSearchFilterFromQuery(query);
|
||||
|
||||
expect(filter.labels).toContain('region!=US');
|
||||
expect(filter.labels).toContain('team=~fe.*devs');
|
||||
expect(filter.labels).toContain('cluster!~ba.+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySearchFilterToQuery', () => {
|
||||
it('should apply filters to an empty query', () => {
|
||||
const filter = getFilter({
|
||||
freeFormWords: ['cpu', 'eighty'],
|
||||
dataSourceName: 'Mimir Dev',
|
||||
namespace: '/etc/prometheus',
|
||||
labels: ['team', 'region=apac'],
|
||||
groupName: 'cpu-usage',
|
||||
ruleName: 'cpu > 80%',
|
||||
ruleType: PromRuleType.Alerting,
|
||||
ruleState: PromAlertingRuleState.Firing,
|
||||
ruleHealth: RuleHealth.Ok,
|
||||
});
|
||||
|
||||
const query = applySearchFilterToQuery('', filter);
|
||||
|
||||
expect(query).toBe(
|
||||
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" state:firing type:alerting health:ok label:team label:region=apac cpu eighty'
|
||||
);
|
||||
});
|
||||
|
||||
it('should update filters in existing query', () => {
|
||||
const filter = getFilter({
|
||||
dataSourceName: 'Mimir Dev',
|
||||
namespace: '/etc/prometheus',
|
||||
labels: ['team', 'region=apac'],
|
||||
groupName: 'cpu-usage',
|
||||
ruleName: 'cpu > 80%',
|
||||
});
|
||||
|
||||
const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"';
|
||||
const query = applySearchFilterToQuery(baseQuery, filter);
|
||||
|
||||
expect(query).toBe(
|
||||
'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" label:team label:region=apac'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve the order of parameters when updating', () => {
|
||||
const filter = getFilter({
|
||||
dataSourceName: 'Mimir Dev',
|
||||
namespace: '/etc/prometheus',
|
||||
labels: ['region=emea'],
|
||||
groupName: 'cpu-usage',
|
||||
ruleName: 'cpu > 80%',
|
||||
});
|
||||
|
||||
const baseQuery = 'label:region=apac rule:"mem > 90%" group:memory namespace:mimir-global datasource:prometheus';
|
||||
const query = applySearchFilterToQuery(baseQuery, filter);
|
||||
|
||||
expect(query).toBe(
|
||||
'label:region=emea rule:"cpu > 80%" group:cpu-usage namespace:/etc/prometheus datasource:"Mimir Dev"'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
100
public/app/features/alerting/unified/search/rulesSearchParser.ts
Normal file
100
public/app/features/alerting/unified/search/rulesSearchParser.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { isPromAlertingRuleState, PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto';
|
||||
import { getRuleHealth, isPromRuleType } from '../utils/rules';
|
||||
|
||||
import * as terms from './search.terms';
|
||||
import {
|
||||
applyFiltersToQuery,
|
||||
FilterExpr,
|
||||
FilterSupportedTerm,
|
||||
parseQueryToFilter,
|
||||
QueryFilterMapper,
|
||||
} from './searchParser';
|
||||
|
||||
export interface RulesFilter {
|
||||
freeFormWords: string[];
|
||||
namespace?: string;
|
||||
groupName?: string;
|
||||
ruleName?: string;
|
||||
ruleState?: PromAlertingRuleState;
|
||||
ruleType?: PromRuleType;
|
||||
dataSourceName?: string;
|
||||
labels: string[];
|
||||
ruleHealth?: RuleHealth;
|
||||
}
|
||||
|
||||
const filterSupportedTerms: FilterSupportedTerm[] = [
|
||||
FilterSupportedTerm.dataSource,
|
||||
FilterSupportedTerm.nameSpace,
|
||||
FilterSupportedTerm.label,
|
||||
FilterSupportedTerm.group,
|
||||
FilterSupportedTerm.rule,
|
||||
FilterSupportedTerm.state,
|
||||
FilterSupportedTerm.type,
|
||||
FilterSupportedTerm.health,
|
||||
];
|
||||
|
||||
export enum RuleHealth {
|
||||
Ok = 'ok',
|
||||
Error = 'error',
|
||||
NoData = 'nodata',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
// Define how to map parsed tokens into the filter object
|
||||
export function getSearchFilterFromQuery(query: string): RulesFilter {
|
||||
const filter: RulesFilter = { labels: [], freeFormWords: [] };
|
||||
|
||||
const tokenToFilterMap: QueryFilterMapper = {
|
||||
[terms.DataSourceToken]: (value) => (filter.dataSourceName = value),
|
||||
[terms.NameSpaceToken]: (value) => (filter.namespace = value),
|
||||
[terms.GroupToken]: (value) => (filter.groupName = value),
|
||||
[terms.RuleToken]: (value) => (filter.ruleName = value),
|
||||
[terms.LabelToken]: (value) => filter.labels.push(value),
|
||||
[terms.StateToken]: (value) => (isPromAlertingRuleState(value) ? (filter.ruleState = value) : undefined),
|
||||
[terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined),
|
||||
[terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)),
|
||||
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
|
||||
};
|
||||
|
||||
parseQueryToFilter(query, filterSupportedTerms, tokenToFilterMap);
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Reverse of the previous function
|
||||
// Describes how to map the object into an array of tokens and values
|
||||
export function applySearchFilterToQuery(query: string, filter: RulesFilter): string {
|
||||
const filterStateArray: FilterExpr[] = [];
|
||||
|
||||
// Convert filter object into an array
|
||||
// It allows to pick filters from the array in the same order as they were applied in the original query
|
||||
if (filter.dataSourceName) {
|
||||
filterStateArray.push({ type: terms.DataSourceToken, value: filter.dataSourceName });
|
||||
}
|
||||
if (filter.namespace) {
|
||||
filterStateArray.push({ type: terms.NameSpaceToken, value: filter.namespace });
|
||||
}
|
||||
if (filter.groupName) {
|
||||
filterStateArray.push({ type: terms.GroupToken, value: filter.groupName });
|
||||
}
|
||||
if (filter.ruleName) {
|
||||
filterStateArray.push({ type: terms.RuleToken, value: filter.ruleName });
|
||||
}
|
||||
if (filter.ruleState) {
|
||||
filterStateArray.push({ type: terms.StateToken, value: filter.ruleState });
|
||||
}
|
||||
if (filter.ruleType) {
|
||||
filterStateArray.push({ type: terms.TypeToken, value: filter.ruleType });
|
||||
}
|
||||
if (filter.ruleHealth) {
|
||||
filterStateArray.push({ type: terms.HealthToken, value: filter.ruleHealth });
|
||||
}
|
||||
if (filter.labels) {
|
||||
filterStateArray.push(...filter.labels.map((l) => ({ type: terms.LabelToken, value: l })));
|
||||
}
|
||||
if (filter.freeFormWords) {
|
||||
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word })));
|
||||
}
|
||||
|
||||
return applyFiltersToQuery(query, filterSupportedTerms, filterStateArray);
|
||||
}
|
54
public/app/features/alerting/unified/search/search.grammar
Normal file
54
public/app/features/alerting/unified/search/search.grammar
Normal file
@ -0,0 +1,54 @@
|
||||
@top AlertRuleSearch { expression+ }
|
||||
|
||||
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter }
|
||||
|
||||
expression { (FilterExpression | FreeFormExpression) expression }
|
||||
|
||||
FreeFormExpression { word (colon word)* | stringWithQuotes }
|
||||
|
||||
FilterExpression {
|
||||
filter<DataSourceToken> |
|
||||
filter<NameSpaceToken> |
|
||||
filter<LabelToken> |
|
||||
filter<GroupToken> |
|
||||
filter<RuleToken> |
|
||||
filter<StateToken> |
|
||||
filter<TypeToken> |
|
||||
filter<HealthToken>
|
||||
}
|
||||
|
||||
filter<token> { token FilterValue }
|
||||
|
||||
@tokens {
|
||||
colon { ":" }
|
||||
|
||||
// Special characters (except colon, quotes and space), Latin characters, extended latin and emoji
|
||||
allowedInputChar { $[!#$%&'()*+,-./] | $[\u{0030}-\u{0039}] | $[\u{003b}-\u{1eff}] | $[\u{2030}-\u{1faff}] }
|
||||
word { allowedInputChar+ }
|
||||
|
||||
allowedInputCharOrColon { allowedInputChar | colon }
|
||||
allowedInputCharOrColonOrWhitespace { allowedInputCharOrColon | @whitespace }
|
||||
stringWithQuotes { ("\"" allowedInputCharOrColonOrWhitespace+ "\"") }
|
||||
|
||||
FilterValue { allowedInputCharOrColon+ | stringWithQuotes }
|
||||
filterToken<type> { type colon }
|
||||
|
||||
DataSourceToken[@dialect=dataSourceFilter] { filterToken<"datasource"> }
|
||||
NameSpaceToken[@dialect=nameSpaceFilter] { filterToken<"namespace"> }
|
||||
LabelToken[@dialect=labelFilter] { filterToken<"label"> }
|
||||
GroupToken[@dialect=groupFilter] { filterToken<"group"> }
|
||||
RuleToken[@dialect=ruleFilter] { filterToken<"rule"> }
|
||||
StateToken[@dialect=stateFilter] { filterToken<"state"> }
|
||||
TypeToken[@dialect=typeFilter] { filterToken<"type"> }
|
||||
HealthToken[@dialect=healthFilter] { filterToken<"health"> }
|
||||
|
||||
@precedence { DataSourceToken, word }
|
||||
@precedence { NameSpaceToken, word }
|
||||
@precedence { LabelToken, word }
|
||||
@precedence { GroupToken, word }
|
||||
@precedence { RuleToken, word }
|
||||
@precedence { StateToken, word }
|
||||
@precedence { TypeToken, word }
|
||||
@precedence { HealthToken, word }
|
||||
}
|
||||
|
30
public/app/features/alerting/unified/search/search.js
Normal file
30
public/app/features/alerting/unified/search/search.js
Normal file
File diff suppressed because one or more lines are too long
21
public/app/features/alerting/unified/search/search.terms.js
Normal file
21
public/app/features/alerting/unified/search/search.terms.js
Normal file
@ -0,0 +1,21 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const AlertRuleSearch = 1,
|
||||
FilterExpression = 2,
|
||||
DataSourceToken = 3,
|
||||
FilterValue = 4,
|
||||
NameSpaceToken = 5,
|
||||
LabelToken = 6,
|
||||
GroupToken = 7,
|
||||
RuleToken = 8,
|
||||
StateToken = 9,
|
||||
TypeToken = 10,
|
||||
HealthToken = 11,
|
||||
FreeFormExpression = 12,
|
||||
Dialect_dataSourceFilter = 0,
|
||||
Dialect_nameSpaceFilter = 1,
|
||||
Dialect_labelFilter = 2,
|
||||
Dialect_groupFilter = 3,
|
||||
Dialect_ruleFilter = 4,
|
||||
Dialect_stateFilter = 5,
|
||||
Dialect_typeFilter = 6,
|
||||
Dialect_healthFilter = 7;
|
144
public/app/features/alerting/unified/search/searchParser.ts
Normal file
144
public/app/features/alerting/unified/search/searchParser.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { trim } from 'lodash';
|
||||
|
||||
import { parser } from './search';
|
||||
import * as terms from './search.terms';
|
||||
|
||||
const filterTokenToTypeMap: Record<number, string> = {
|
||||
[terms.DataSourceToken]: 'datasource',
|
||||
[terms.NameSpaceToken]: 'namespace',
|
||||
[terms.LabelToken]: 'label',
|
||||
[terms.RuleToken]: 'rule',
|
||||
[terms.GroupToken]: 'group',
|
||||
[terms.StateToken]: 'state',
|
||||
[terms.TypeToken]: 'type',
|
||||
[terms.HealthToken]: 'health',
|
||||
};
|
||||
|
||||
// This enum allows to configure parser behavior
|
||||
// Depending on our needs we can enable and disable only selected filters
|
||||
// Thanks to that we can create multiple different filters having the same search grammar
|
||||
export enum FilterSupportedTerm {
|
||||
dataSource = 'dataSourceFilter',
|
||||
nameSpace = 'nameSpaceFilter',
|
||||
label = 'labelFilter',
|
||||
group = 'groupFilter',
|
||||
rule = 'ruleFilter',
|
||||
state = 'stateFilter',
|
||||
type = 'typeFilter',
|
||||
health = 'healthFilter',
|
||||
}
|
||||
|
||||
export type QueryFilterMapper = Record<number, (filter: string) => void>;
|
||||
|
||||
export interface FilterExpr {
|
||||
type: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function parseQueryToFilter(
|
||||
query: string,
|
||||
supportedTerms: FilterSupportedTerm[],
|
||||
filterMapper: QueryFilterMapper
|
||||
) {
|
||||
traverseNodeTree(query, supportedTerms, (node) => {
|
||||
if (node.type.id === terms.FilterExpression) {
|
||||
const filter = getFilterFromSyntaxNode(query, node);
|
||||
|
||||
if (filter.type && filter.value) {
|
||||
const filterHandler = filterMapper[filter.type];
|
||||
if (filterHandler) {
|
||||
filterHandler(filter.value);
|
||||
}
|
||||
}
|
||||
} else if (node.type.id === terms.FreeFormExpression) {
|
||||
const filterHandler = filterMapper[terms.FreeFormExpression];
|
||||
if (filterHandler) {
|
||||
filterHandler(getNodeContent(query, node));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterFromSyntaxNode(query: string, filterExpressionNode: SyntaxNode): { type?: number; value?: string } {
|
||||
if (filterExpressionNode.type.id !== terms.FilterExpression) {
|
||||
throw new Error('Invalid node provided. Only FilterExpression nodes are supported');
|
||||
}
|
||||
|
||||
const filterTokenNode = filterExpressionNode.firstChild;
|
||||
if (!filterTokenNode) {
|
||||
return { type: undefined, value: undefined };
|
||||
}
|
||||
|
||||
const filterValueNode = filterExpressionNode.getChild(terms.FilterValue);
|
||||
const filterValue = filterValueNode ? trim(getNodeContent(query, filterValueNode), '"') : undefined;
|
||||
|
||||
return { type: filterTokenNode.type.id, value: filterValue };
|
||||
}
|
||||
|
||||
function getNodeContent(query: string, node: SyntaxNode) {
|
||||
return query.slice(node.from, node.to).trim().replace(/\"/g, '');
|
||||
}
|
||||
|
||||
export function applyFiltersToQuery(
|
||||
query: string,
|
||||
supportedTerms: FilterSupportedTerm[],
|
||||
filters: FilterExpr[]
|
||||
): string {
|
||||
const existingFilterNodes: SyntaxNode[] = [];
|
||||
traverseNodeTree(query, supportedTerms, (node) => {
|
||||
if (node.type.id === terms.FilterExpression && node.firstChild) {
|
||||
existingFilterNodes.push(node.firstChild);
|
||||
}
|
||||
if (node.type.id === terms.FreeFormExpression) {
|
||||
existingFilterNodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
let newQueryExpressions: string[] = [];
|
||||
|
||||
// Apply filters from filterState in the same order as they appear in the search query
|
||||
// This allows to remain the order of filters in the search input during changes
|
||||
existingFilterNodes.forEach((filterNode) => {
|
||||
const matchingFilterIdx = filters.findIndex((f) => f.type === filterNode.type.id);
|
||||
if (matchingFilterIdx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterNode.parent?.type.is(terms.FilterExpression)) {
|
||||
const filterToken = filterTokenToTypeMap[filterNode.type.id];
|
||||
const filterItem = filters.splice(matchingFilterIdx, 1)[0];
|
||||
newQueryExpressions.push(`${filterToken}:${getSafeFilterValue(filterItem.value)}`);
|
||||
}
|
||||
|
||||
if (filterNode.type.is(terms.FreeFormExpression)) {
|
||||
const freeFormWordNode = filters.splice(matchingFilterIdx, 1)[0];
|
||||
newQueryExpressions.push(freeFormWordNode.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply new filters that hasn't been in the query yet
|
||||
filters.forEach((fs) => {
|
||||
if (fs.type === terms.FreeFormExpression) {
|
||||
newQueryExpressions.push(fs.value);
|
||||
} else {
|
||||
newQueryExpressions.push(`${filterTokenToTypeMap[fs.type]}:${getSafeFilterValue(fs.value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return newQueryExpressions.join(' ');
|
||||
}
|
||||
|
||||
function traverseNodeTree(query: string, supportedTerms: FilterSupportedTerm[], visit: (node: SyntaxNode) => void) {
|
||||
const dialect = supportedTerms.join(' ');
|
||||
const parsed = parser.configure({ dialect }).parse(query);
|
||||
let cursor = parsed.cursor();
|
||||
do {
|
||||
visit(cursor.node);
|
||||
} while (cursor.next());
|
||||
}
|
||||
|
||||
function getSafeFilterValue(filterValue: string) {
|
||||
const containsWhiteSpaces = /\s/.test(filterValue);
|
||||
return containsWhiteSpaces ? `\"${filterValue}\"` : filterValue;
|
||||
}
|
@ -27,6 +27,7 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { State } from '../components/StateTag';
|
||||
import { RuleHealth } from '../search/rulesSearchParser';
|
||||
|
||||
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
||||
import { AsyncRequestState } from './redux';
|
||||
@ -67,10 +68,30 @@ export function isCloudRuleIdentifier(identifier: RuleIdentifier): identifier is
|
||||
return 'rulerRuleHash' in identifier;
|
||||
}
|
||||
|
||||
export function isPromRuleType(ruleType: string): ruleType is PromRuleType {
|
||||
return Object.values<string>(PromRuleType).includes(ruleType);
|
||||
}
|
||||
|
||||
export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifier is PrometheusRuleIdentifier {
|
||||
return 'ruleHash' in identifier;
|
||||
}
|
||||
|
||||
export function getRuleHealth(health: string): RuleHealth | undefined {
|
||||
switch (health) {
|
||||
case 'ok':
|
||||
return RuleHealth.Ok;
|
||||
case 'nodata':
|
||||
return RuleHealth.NoData;
|
||||
case 'error':
|
||||
case 'err': // Prometheus-compat data sources
|
||||
return RuleHealth.Error;
|
||||
case 'unknown':
|
||||
return RuleHealth.Unknown;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): string {
|
||||
if (state === PromAlertingRuleState.Inactive) {
|
||||
return 'Normal';
|
||||
|
9
public/app/features/alerting/unified/utils/search.ts
Normal file
9
public/app/features/alerting/unified/utils/search.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
|
||||
export function getFilter(filter: Partial<RulesFilter>): RulesFilter {
|
||||
return {
|
||||
freeFormWords: [],
|
||||
labels: [],
|
||||
...filter,
|
||||
};
|
||||
}
|
@ -23,6 +23,10 @@ type GrafanaAlertStateReason = ` (${string})` | '';
|
||||
|
||||
export type GrafanaAlertStateWithReason = `${GrafanaAlertState}${GrafanaAlertStateReason}`;
|
||||
|
||||
export function isPromAlertingRuleState(state: string): state is PromAlertingRuleState {
|
||||
return Object.values<string>(PromAlertingRuleState).includes(state);
|
||||
}
|
||||
|
||||
export function isGrafanaAlertState(state: string): state is GrafanaAlertState {
|
||||
return Object.values(GrafanaAlertState).some((promState) => promState === state);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user