Alerting: Alert rules search improvements (#61398)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2023-01-26 13:44:14 +01:00 committed by GitHub
parent 94dca85b30
commit e8dd01df35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1108 additions and 166 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View File

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

View 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);
}

View 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 }
}

File diff suppressed because one or more lines are too long

View 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;

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

View File

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

View File

@ -0,0 +1,9 @@
import { RulesFilter } from '../search/rulesSearchParser';
export function getFilter(filter: Partial<RulesFilter>): RulesFilter {
return {
freeFormWords: [],
labels: [],
...filter,
};
}

View File

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