Alerting: Add hide plugins rules filter (#87445)

* Add hide plugins rules filter

* Change the filter label and available values

* Setup plugins hook in tests

* Fix lint errors

* remove unused code
pull/87908/head
Konrad Lalik 2 years ago committed by GitHub
parent 4fef6ff30d
commit 1cf1c5f03a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      public/app/features/alerting/unified/RuleList.test.tsx
  2. 3
      public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx
  3. 28
      public/app/features/alerting/unified/components/rules/RulesFilter.tsx
  4. 9
      public/app/features/alerting/unified/home/PluginIntegrations.tsx
  5. 18
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  6. 9
      public/app/features/alerting/unified/plugins/useAlertingHomePageExtensions.ts
  7. 6
      public/app/features/alerting/unified/search/rulesSearchParser.ts
  8. 7
      public/app/features/alerting/unified/search/search.grammar
  9. 33
      public/app/features/alerting/unified/search/search.js
  10. 6
      public/app/features/alerting/unified/search/search.terms.js
  11. 2
      public/app/features/alerting/unified/search/searchParser.ts
  12. 16
      public/app/features/alerting/unified/testSetup/plugins.ts

@ -54,6 +54,7 @@ import {
somePromRules, somePromRules,
someRulerRules, someRulerRules,
} from './mocks'; } from './mocks';
import { setupPluginsExtensionsHook } from './testSetup/plugins';
import * as config from './utils/config'; import * as config from './utils/config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
@ -83,6 +84,8 @@ jest.spyOn(config, 'getAllDataSources');
jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]); jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
jest.spyOn(apiRuler, 'rulerUrlBuilder'); jest.spyOn(apiRuler, 'rulerUrlBuilder');
setupPluginsExtensionsHook();
const mocks = { const mocks = {
getAllDataSourcesMock: jest.mocked(config.getAllDataSources), getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),

@ -9,6 +9,7 @@ import { mockSearchApi, setupMswServer } from 'app/features/alerting/unified/moc
import * as analytics from '../../Analytics'; import * as analytics from '../../Analytics';
import { MockDataSourceSrv } from '../../mocks'; import { MockDataSourceSrv } from '../../mocks';
import { setupPluginsExtensionsHook } from '../../testSetup/plugins';
import RulesFilter from './RulesFilter'; import RulesFilter from './RulesFilter';
@ -25,6 +26,8 @@ jest.mock('./MultipleDataSourcePicker', () => {
setDataSourceSrv(new MockDataSourceSrv({})); setDataSourceSrv(new MockDataSourceSrv({}));
setupPluginsExtensionsHook();
const ui = { const ui = {
stateFilter: { stateFilter: {
firing: byRole('radio', { name: 'Firing' }), firing: byRole('radio', { name: 'Firing' }),

@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { import {
@ -16,6 +15,8 @@ import {
trackRulesSearchInputInteraction, trackRulesSearchInputInteraction,
} from '../../Analytics'; } from '../../Analytics';
import { useRulesFilter } from '../../hooks/useFilteredRules'; import { useRulesFilter } from '../../hooks/useFilteredRules';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions';
import { RuleHealth } from '../../search/rulesSearchParser'; import { RuleHealth } from '../../search/rulesSearchParser';
import { alertStateToReadable } from '../../utils/rules'; import { alertStateToReadable } from '../../utils/rules';
import { HoverCard } from '../HoverCard'; import { HoverCard } from '../HoverCard';
@ -68,7 +69,8 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]
const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => { const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [queryParams, setQueryParams] = useQueryParams(); const [queryParams, updateQueryParams] = useURLSearchParams();
const { pluginsFilterEnabled } = usePluginsFilterStatus();
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter(); const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
// This key is used to force a rerender on the inputs when the filters are cleared // This key is used to force a rerender on the inputs when the filters are cleared
@ -133,7 +135,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
}; };
const handleViewChange = (view: string) => { const handleViewChange = (view: string) => {
setQueryParams({ view }); updateQueryParams({ view });
trackRulesListViewChange({ view }); trackRulesListViewChange({ view });
}; };
@ -220,6 +222,19 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
onChange={handleRuleHealthChange} onChange={handleRuleHealthChange}
/> />
</div> </div>
{pluginsFilterEnabled && (
<div>
<Label>Plugin rules</Label>
<RadioButtonGroup<'hide'>
options={[
{ label: 'Show', value: undefined },
{ label: 'Hide', value: 'hide' },
]}
value={filterState.plugins}
onChange={(value) => updateFilters({ ...filterState, plugins: value })}
/>
</div>
)}
</Stack> </Stack>
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
<Stack direction="row" gap={1}> <Stack direction="row" gap={1}>
@ -262,7 +277,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
<Label>View as</Label> <Label>View as</Label>
<RadioButtonGroup <RadioButtonGroup
options={ViewOptions} options={ViewOptions}
value={String(queryParams['view'] ?? ViewOptions[0].value)} value={queryParams.get('view') ?? ViewOptions[0].value}
onChange={handleViewChange} onChange={handleViewChange}
/> />
</div> </div>
@ -348,4 +363,9 @@ const helpStyles = (theme: GrafanaTheme2) => ({
}), }),
}); });
function usePluginsFilterStatus() {
const { extensions } = useAlertingHomePageExtensions();
return { pluginsFilterEnabled: extensions.length > 0 };
}
export default RulesFilter; export default RulesFilter;

@ -1,19 +1,16 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { PluginExtensionPoints } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data/'; import { GrafanaTheme2 } from '@grafana/data/';
import { usePluginComponentExtensions } from '@grafana/runtime';
import { Stack, Text } from '@grafana/ui'; import { Stack, Text } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/'; import { useStyles2 } from '@grafana/ui/';
import { useAlertingHomePageExtensions } from '../plugins/useAlertingHomePageExtensions';
export function PluginIntegrations() { export function PluginIntegrations() {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { extensions } = usePluginComponentExtensions({ const { extensions } = useAlertingHomePageExtensions();
extensionPointId: PluginExtensionPoints.AlertingHomePage,
limitPerPlugin: 1,
});
if (extensions.length === 0) { if (extensions.length === 0) {
return null; return null;

@ -13,7 +13,13 @@ import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../ut
import { Annotation } from '../utils/constants'; import { Annotation } from '../utils/constants';
import { isCloudRulesSource } from '../utils/datasource'; import { isCloudRulesSource } from '../utils/datasource';
import { parseMatcher } from '../utils/matchers'; import { parseMatcher } from '../utils/matchers';
import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; import {
getRuleHealth,
isAlertingRule,
isGrafanaRulerRule,
isPluginProvidedRule,
isPromRuleType,
} from '../utils/rules';
import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces'; import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces';
import { useURLSearchParams } from './useURLSearchParams'; import { useURLSearchParams } from './useURLSearchParams';
@ -26,7 +32,9 @@ export function useRulesFilter() {
const [queryParams, updateQueryParams] = useURLSearchParams(); const [queryParams, updateQueryParams] = useURLSearchParams();
const searchQuery = queryParams.get('search') ?? ''; const searchQuery = queryParams.get('search') ?? '';
const filterState = useMemo(() => getSearchFilterFromQuery(searchQuery), [searchQuery]); const filterState = useMemo<RulesFilter>(() => {
return getSearchFilterFromQuery(searchQuery);
}, [searchQuery]);
const hasActiveFilters = useMemo(() => Object.values(filterState).some((filter) => !isEmpty(filter)), [filterState]); const hasActiveFilters = useMemo(() => Object.values(filterState).some((filter) => !isEmpty(filter)), [filterState]);
const updateFilters = useCallback( const updateFilters = useCallback(
@ -208,7 +216,7 @@ const reduceGroups = (filterState: RulesFilter) => {
const matchesFilterFor = chain(filterState) const matchesFilterFor = chain(filterState)
// ⚠ keep this list of properties we filter for here up-to-date ⚠ // ⚠ keep this list of properties we filter for here up-to-date ⚠
// We are ignoring predicates we've matched before we get here (like "freeFormWords") // We are ignoring predicates we've matched before we get here (like "freeFormWords")
.pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState', 'dashboardUid']) .pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState', 'dashboardUid', 'plugins'])
.omitBy(isEmpty) .omitBy(isEmpty)
.mapValues(() => false) .mapValues(() => false)
.value(); .value();
@ -217,6 +225,10 @@ const reduceGroups = (filterState: RulesFilter) => {
matchesFilterFor.ruleType = true; matchesFilterFor.ruleType = true;
} }
if ('plugins' in matchesFilterFor && filterState.plugins === 'hide') {
matchesFilterFor.plugins = !isPluginProvidedRule(rule);
}
if ('dataSourceNames' in matchesFilterFor) { if ('dataSourceNames' in matchesFilterFor) {
if (isGrafanaRulerRule(rule.rulerRule)) { if (isGrafanaRulerRule(rule.rulerRule)) {
const doesNotQueryDs = isQueryingDataSource(rule.rulerRule, filterState); const doesNotQueryDs = isQueryingDataSource(rule.rulerRule, filterState);

@ -0,0 +1,9 @@
import { PluginExtensionPoints } from '@grafana/data';
import { usePluginComponentExtensions } from '@grafana/runtime';
export function useAlertingHomePageExtensions() {
return usePluginComponentExtensions({
extensionPointId: PluginExtensionPoints.AlertingHomePage,
limitPerPlugin: 1,
});
}

@ -21,6 +21,7 @@ export interface RulesFilter {
labels: string[]; labels: string[];
ruleHealth?: RuleHealth; ruleHealth?: RuleHealth;
dashboardUid?: string; dashboardUid?: string;
plugins?: 'hide';
} }
const filterSupportedTerms: FilterSupportedTerm[] = [ const filterSupportedTerms: FilterSupportedTerm[] = [
@ -33,6 +34,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [
FilterSupportedTerm.type, FilterSupportedTerm.type,
FilterSupportedTerm.health, FilterSupportedTerm.health,
FilterSupportedTerm.dashboard, FilterSupportedTerm.dashboard,
FilterSupportedTerm.plugins,
]; ];
export enum RuleHealth { export enum RuleHealth {
@ -56,6 +58,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter {
[terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined), [terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined),
[terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)), [terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)),
[terms.DashboardToken]: (value) => (filter.dashboardUid = value), [terms.DashboardToken]: (value) => (filter.dashboardUid = value),
[terms.PluginsToken]: (value) => (filter.plugins = value === 'hide' ? value : undefined),
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value), [terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
}; };
@ -98,6 +101,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
if (filter.dashboardUid) { if (filter.dashboardUid) {
filterStateArray.push({ type: terms.DashboardToken, value: filter.dashboardUid }); filterStateArray.push({ type: terms.DashboardToken, value: filter.dashboardUid });
} }
if (filter.plugins) {
filterStateArray.push({ type: terms.PluginsToken, value: filter.plugins });
}
if (filter.freeFormWords) { if (filter.freeFormWords) {
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word }))); filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word })));
} }

@ -1,6 +1,6 @@
@top AlertRuleSearch { expression+ } @top AlertRuleSearch { expression+ }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter } @dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter, pluginsFilter }
expression { (FilterExpression | FreeFormExpression) expression } expression { (FilterExpression | FreeFormExpression) expression }
@ -15,7 +15,8 @@ FilterExpression {
filter<StateToken> | filter<StateToken> |
filter<TypeToken> | filter<TypeToken> |
filter<HealthToken> | filter<HealthToken> |
filter<DashboardToken> filter<DashboardToken> |
filter<PluginsToken>
} }
filter<token> { token FilterValue } filter<token> { token FilterValue }
@ -43,6 +44,7 @@ filter<token> { token FilterValue }
TypeToken[@dialect=typeFilter] { filterToken<"type"> } TypeToken[@dialect=typeFilter] { filterToken<"type"> }
HealthToken[@dialect=healthFilter] { filterToken<"health"> } HealthToken[@dialect=healthFilter] { filterToken<"health"> }
DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> } DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> }
PluginsToken[@dialect=pluginsFilter] { filterToken<"plugins"> }
@precedence { DataSourceToken, word } @precedence { DataSourceToken, word }
@precedence { NameSpaceToken, word } @precedence { NameSpaceToken, word }
@ -53,5 +55,6 @@ filter<token> { token FilterValue }
@precedence { TypeToken, word } @precedence { TypeToken, word }
@precedence { HealthToken, word } @precedence { HealthToken, word }
@precedence { DashboardToken, word } @precedence { DashboardToken, word }
@precedence { PluginsToken, word }
} }

File diff suppressed because one or more lines are too long

@ -11,7 +11,8 @@ export const AlertRuleSearch = 1,
TypeToken = 10, TypeToken = 10,
HealthToken = 11, HealthToken = 11,
DashboardToken = 12, DashboardToken = 12,
FreeFormExpression = 13, PluginsToken = 13,
FreeFormExpression = 14,
Dialect_dataSourceFilter = 0, Dialect_dataSourceFilter = 0,
Dialect_nameSpaceFilter = 1, Dialect_nameSpaceFilter = 1,
Dialect_labelFilter = 2, Dialect_labelFilter = 2,
@ -20,4 +21,5 @@ export const AlertRuleSearch = 1,
Dialect_stateFilter = 5, Dialect_stateFilter = 5,
Dialect_typeFilter = 6, Dialect_typeFilter = 6,
Dialect_healthFilter = 7, Dialect_healthFilter = 7,
Dialect_dashboardFilter = 8; Dialect_dashboardFilter = 8,
Dialect_pluginsFilter = 9;

@ -14,6 +14,7 @@ const filterTokenToTypeMap: Record<number, string> = {
[terms.TypeToken]: 'type', [terms.TypeToken]: 'type',
[terms.HealthToken]: 'health', [terms.HealthToken]: 'health',
[terms.DashboardToken]: 'dashboard', [terms.DashboardToken]: 'dashboard',
[terms.PluginsToken]: 'plugins',
}; };
// This enum allows to configure parser behavior // This enum allows to configure parser behavior
@ -29,6 +30,7 @@ export enum FilterSupportedTerm {
type = 'typeFilter', type = 'typeFilter',
health = 'healthFilter', health = 'healthFilter',
dashboard = 'dashboardFilter', dashboard = 'dashboardFilter',
plugins = 'pluginsFilter',
} }
export type QueryFilterMapper = Record<number, (filter: string) => void>; export type QueryFilterMapper = Record<number, (filter: string) => void>;

@ -1,8 +1,9 @@
import { RequestHandler } from 'msw'; import { RequestHandler } from 'msw';
import { PluginMeta, PluginType } from '@grafana/data'; import { PluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config, setPluginExtensionsHook } from '@grafana/runtime';
import { mockPluginLinkExtension } from '../mocks';
import { pluginsHandler } from '../mocks/plugins'; import { pluginsHandler } from '../mocks/plugins';
export function setupPlugins(plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } { export function setupPlugins(plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } {
@ -21,6 +22,19 @@ export function setupPlugins(plugins: PluginMeta[]): { apiHandlers: RequestHandl
}; };
} }
export function setupPluginsExtensionsHook() {
setPluginExtensionsHook(() => ({
extensions: plugins.map((plugin) =>
mockPluginLinkExtension({
pluginId: plugin.id,
title: plugin.name,
path: `/a/${plugin.id}`,
})
),
isLoading: false,
}));
}
export const plugins: PluginMeta[] = [ export const plugins: PluginMeta[] = [
{ {
id: 'grafana-slo-app', id: 'grafana-slo-app',

Loading…
Cancel
Save