Alerting: Improve integration with dashboards (#80201)

* Add filtering by dashboard UID annotation

* Update the inline doc for search

* Add AlertRulesDrawer to the dashboards toolbar

* Use DashboardPicker as a filter on the alert rules page

* Fix accessibility errors

* Update drawer subtitle

* Display Alerting toolbar button only if there are linked alert rules

* Change toolbar rendering method, prevent displaying when no linked rules

* Improve text

* Use React.lazy to load the Alert rule toolbar button and drawer when needed
This commit is contained in:
Konrad Lalik 2024-01-29 16:09:10 +01:00 committed by GitHub
parent 75e1f7aa5e
commit 26e71953a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 267 additions and 64 deletions

View File

@ -2102,13 +2102,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/alerting/unified/components/rules/RulesFilter.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
],
"public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],

View File

@ -71,6 +71,7 @@ import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/gra
import { KeybindingSrv } from './core/services/keybindingSrv';
import { startMeasure, stopMeasure } from './core/utils/metrics';
import { initDevFeatures } from './dev';
import { initAlerting } from './features/alerting/unified/initAlerting';
import { initAuthConfig } from './features/auth-config';
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy';
@ -153,6 +154,8 @@ export class GrafanaApp {
configureStore();
initExtensions();
initAlerting();
standardEditorsRegistry.setInit(getAllOptionEditors);
standardFieldConfigEditorRegistry.setInit(getAllStandardFieldConfigs);
standardTransformersRegistry.setInit(getStandardTransformers);

View File

@ -154,15 +154,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({
prometheusRuleNamespaces: build.query<
RuleNamespace[],
{ ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string }
{ ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string; dashboardUid?: string }
>({
query: ({ ruleSourceName, namespace, groupName, ruleName }) => {
const queryParams: Record<string, string | undefined> = {};
// if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
queryParams['file'] = namespace;
queryParams['rule_group'] = groupName;
queryParams['rule_name'] = ruleName;
// }
query: ({ ruleSourceName, namespace, groupName, ruleName, dashboardUid }) => {
const queryParams: Record<string, string | undefined> = {
file: namespace,
rule_group: groupName,
rule_name: ruleName,
dashboard_uid: dashboardUid, // Supported only by Grafana managed rules
};
return {
url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`,

View File

@ -3,7 +3,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles2, Stack } 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 { useQueryParams } from 'app/core/hooks/useQueryParams';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
@ -91,6 +92,10 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
setFilterKey((key) => key + 1);
};
const handleDashboardChange = (dashboardUid: string | undefined) => {
updateFilters({ ...filterState, dashboardUid });
};
const clearDataSource = () => {
updateFilters({ ...filterState, dataSourceNames: [] });
setFilterKey((key) => key + 1);
@ -99,7 +104,6 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
const handleAlertStateChange = (value: PromAlertingRuleState) => {
logInfo(LogMessages.clickingAlertStateFilters);
updateFilters({ ...filterState, ruleState: value });
setFilterKey((key) => key + 1);
};
const handleViewChange = (view: string) => {
@ -108,12 +112,10 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
const handleRuleTypeChange = (ruleType: PromRuleType) => {
updateFilters({ ...filterState, ruleType });
setFilterKey((key) => key + 1);
};
const handleRuleHealthChange = (ruleHealth: RuleHealth) => {
updateFilters({ ...filterState, ruleHealth });
setFilterKey((key) => key + 1);
};
const handleClearFiltersClick = () => {
@ -127,7 +129,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
return (
<div className={styles.container}>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<Stack direction="row" gap={1} wrap="wrap">
<Field
className={styles.dsPickerContainer}
label={
@ -148,7 +150,7 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
</div>
}
>
<Icon name="info-circle" size="sm" />
<Icon id="data-source-picker-inline-help" name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
@ -165,6 +167,22 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
/>
</Field>
<Field
className={styles.dashboardPickerContainer}
label={<Label htmlFor="filters-dashboard-picker">Dashboard</Label>}
>
{/* The key prop is to clear the picker value */}
{/* DashboardPicker doesn't do that itself when value is undefined */}
<DashboardPicker
inputId="filters-dashboard-picker"
key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'}
value={filterState.dashboardUid}
onChange={(value) => handleDashboardChange(value?.uid)}
isClearable
cacheOptions
/>
</Field>
<div>
<Label>State</Label>
<RadioButtonGroup
@ -246,18 +264,21 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
margin-bottom: ${theme.spacing(1)};
`,
dsPickerContainer: css`
width: 550px;
flex-grow: 0;
margin: 0;
`,
searchInput: css`
flex: 1;
margin: 0;
`,
container: css({
marginBottom: theme.spacing(1),
}),
dsPickerContainer: css({
width: theme.spacing(60),
flexGrow: 0,
margin: 0,
}),
dashboardPickerContainer: css({
minWidth: theme.spacing(50),
}),
searchInput: css({
flex: 1,
margin: 0,
}),
};
};
@ -279,6 +300,7 @@ function SearchQueryHelp() {
<HelpRow title="State" expr="state:firing|normal|pending" />
<HelpRow title="Type" expr="type:alerting|recording" />
<HelpRow title="Health" expr="health:ok|nodata|error" />
<HelpRow title="Dashboard UID" expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" />
</div>
</div>
);
@ -296,16 +318,16 @@ function HelpRow({ title, expr }: { title: string; expr: string }) {
}
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;
`,
grid: css({
display: 'grid',
gridTemplateColumns: 'max-content auto',
gap: theme.spacing(1),
alignItems: 'center',
}),
code: css({
display: 'block',
textAlign: 'center',
}),
});
export default RulesFilter;

View File

@ -15,6 +15,7 @@ import {
mockRulerGrafanaRule,
} from '../mocks';
import { RuleHealth } from '../search/rulesSearchParser';
import { Annotation } from '../utils/constants';
import { getFilter } from '../utils/search';
import { filterRules } from './useFilteredRules';
@ -230,4 +231,29 @@ describe('filterRules', function () {
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 dashboard UID', () => {
const rules = [
mockCombinedRule({
name: 'Memory too low',
annotations: { [Annotation.dashboardUID]: 'dashboard-memory' },
}),
mockCombinedRule({
name: 'CPU too high',
annotations: { [Annotation.dashboardUID]: 'dashboard-cpu' },
}),
mockCombinedRule({
name: 'Disk is dead',
}),
];
const ns = mockCombinedRuleNamespace({
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
});
const filtered = filterRules([ns], getFilter({ dashboardUid: 'dashboard-cpu' }));
expect(filtered[0]?.groups[0]?.rules).toHaveLength(1);
expect(filtered[0]?.groups[0]?.rules[0]?.name).toBe('CPU too high');
});
});

View File

@ -10,6 +10,7 @@ import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/
import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser';
import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../utils/alertmanager';
import { Annotation } from '../utils/constants';
import { isCloudRulesSource } from '../utils/datasource';
import { parseMatcher } from '../utils/matchers';
import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules';
@ -194,7 +195,7 @@ const reduceGroups = (filterState: RulesFilter) => {
const matchesFilterFor = chain(filterState)
// ⚠️ 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")
.pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState'])
.pick(['ruleType', 'dataSourceNames', 'ruleHealth', 'labels', 'ruleState', 'dashboardUid'])
.omitBy(isEmpty)
.mapValues(() => false)
.value();
@ -253,6 +254,13 @@ const reduceGroups = (filterState: RulesFilter) => {
}
}
if (
'dashboardUid' in matchesFilterFor &&
rule.annotations[Annotation.dashboardUID] === filterState.dashboardUid
) {
matchesFilterFor.dashboardUid = true;
}
return Object.values(matchesFilterFor).every((match) => match === true);
});

View File

@ -0,0 +1,21 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { addCustomRightAction } from '../../dashboard/components/DashNav/DashNav';
const AlertRulesToolbarButton = React.lazy(
() => import(/* webpackChunkName: "alert-rules-toolbar-button" */ './integration/AlertRulesToolbarButton')
);
export function initAlerting() {
addCustomRightAction({
show: () => config.unifiedAlertingEnabled,
component: ({ dashboard }) => (
<React.Suspense fallback={null}>
{dashboard && <AlertRulesToolbarButton dashboardUid={dashboard.uid} />}
</React.Suspense>
),
index: -2,
});
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Drawer, LoadingPlaceholder, Stack, TextLink } from '@grafana/ui';
import { t } from '../../../../core/internationalization';
import { createUrl } from '../utils/url';
const AlertRulesDrawerContent = React.lazy(
() => import(/* webpackChunkName: "alert-rules-drawer-content" */ './AlertRulesDrawerContent')
);
interface Props {
dashboardUid: string;
onClose: () => void;
}
export function AlertRulesDrawer({ dashboardUid, onClose }: Props) {
return (
<Drawer title="Alert rules" subtitle={<DrawerSubtitle dashboardUid={dashboardUid} />} onClose={onClose} size="lg">
<React.Suspense fallback={<LoadingPlaceholder text="Loading alert rules" />}>
<AlertRulesDrawerContent dashboardUid={dashboardUid} />
</React.Suspense>
</Drawer>
);
}
function DrawerSubtitle({ dashboardUid }: { dashboardUid: string }) {
const searchParams = new URLSearchParams({ search: `dashboard:${dashboardUid}` });
return (
<Stack gap={2}>
<div>{t('dashboard.toolbar.alert-rules.subtitle', 'Alert rules related to this dashboard')}</div>
<TextLink href={createUrl(`/alerting/list/?${searchParams.toString()}`)}>
{t('dashboard.toolbar.alert-rules.redirect-link', 'List in Grafana Alerting')}
</TextLink>
</Stack>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { useAsync } from 'react-use';
import { LoadingPlaceholder } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { RulesTable } from '../components/rules/RulesTable';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
import { fetchPromAndRulerRulesAction } from '../state/actions';
import { Annotation } from '../utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
interface Props {
dashboardUid: string;
}
export default function AlertRulesDrawerContent({ dashboardUid }: Props) {
const dispatch = useDispatch();
const { loading } = useAsync(async () => {
await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
}, [dispatch]);
const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const rules = grafanaNamespaces
.flatMap((ns) => ns.groups)
.flatMap((g) => g.rules)
.filter((rule) => rule.annotations[Annotation.dashboardUID] === dashboardUid);
return (
<>
{loading ? (
<LoadingPlaceholder text="Loading alert rules" />
) : (
<RulesTable rules={rules} showNextEvaluationColumn={false} showGroupColumn={false} />
)}
</>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { useToggle } from 'react-use';
import { ToolbarButton } from '@grafana/ui';
import { t } from '../../../../core/internationalization';
import { alertRuleApi } from '../api/alertRuleApi';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { AlertRulesDrawer } from './AlertRulesDrawer';
interface AlertRulesToolbarButtonProps {
dashboardUid: string;
}
export default function AlertRulesToolbarButton({ dashboardUid }: AlertRulesToolbarButtonProps) {
const [showDrawer, toggleShowDrawer] = useToggle(false);
const { data: namespaces = [] } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({
ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
dashboardUid: dashboardUid,
});
if (namespaces.length === 0) {
return null;
}
return (
<>
<ToolbarButton
tooltip={t('dashboard.toolbar.alert-rules', 'Alert rules')}
icon="bell"
onClick={toggleShowDrawer}
key="button-alerting"
/>
{showDrawer && <AlertRulesDrawer dashboardUid={dashboardUid} onClose={toggleShowDrawer} />}
</>
);
}

View File

@ -20,6 +20,7 @@ export interface RulesFilter {
dataSourceNames: string[];
labels: string[];
ruleHealth?: RuleHealth;
dashboardUid?: string;
}
const filterSupportedTerms: FilterSupportedTerm[] = [
@ -31,6 +32,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [
FilterSupportedTerm.state,
FilterSupportedTerm.type,
FilterSupportedTerm.health,
FilterSupportedTerm.dashboard,
];
export enum RuleHealth {
@ -53,6 +55,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter {
[terms.StateToken]: (value) => (filter.ruleState = parseStateToken(value)),
[terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined),
[terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)),
[terms.DashboardToken]: (value) => (filter.dashboardUid = value),
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
};
@ -92,6 +95,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
if (filter.labels) {
filterStateArray.push(...filter.labels.map((l) => ({ type: terms.LabelToken, value: l })));
}
if (filter.dashboardUid) {
filterStateArray.push({ type: terms.DashboardToken, value: filter.dashboardUid });
}
if (filter.freeFormWords) {
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word })));
}

View File

@ -1,6 +1,6 @@
@top AlertRuleSearch { expression+ }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter }
expression { (FilterExpression | FreeFormExpression) expression }
@ -14,7 +14,8 @@ FilterExpression {
filter<RuleToken> |
filter<StateToken> |
filter<TypeToken> |
filter<HealthToken>
filter<HealthToken> |
filter<DashboardToken>
}
filter<token> { token FilterValue }
@ -41,6 +42,7 @@ filter<token> { token FilterValue }
StateToken[@dialect=stateFilter] { filterToken<"state"> }
TypeToken[@dialect=typeFilter] { filterToken<"type"> }
HealthToken[@dialect=healthFilter] { filterToken<"health"> }
DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> }
@precedence { DataSourceToken, word }
@precedence { NameSpaceToken, word }
@ -50,5 +52,6 @@ filter<token> { token FilterValue }
@precedence { StateToken, word }
@precedence { TypeToken, word }
@precedence { HealthToken, word }
@precedence { DashboardToken, word }
}

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,8 @@ export const AlertRuleSearch = 1,
StateToken = 9,
TypeToken = 10,
HealthToken = 11,
FreeFormExpression = 12,
DashboardToken = 12,
FreeFormExpression = 13,
Dialect_dataSourceFilter = 0,
Dialect_nameSpaceFilter = 1,
Dialect_labelFilter = 2,
@ -18,4 +19,5 @@ export const AlertRuleSearch = 1,
Dialect_ruleFilter = 4,
Dialect_stateFilter = 5,
Dialect_typeFilter = 6,
Dialect_healthFilter = 7;
Dialect_healthFilter = 7,
Dialect_dashboardFilter = 8;

View File

@ -13,6 +13,7 @@ const filterTokenToTypeMap: Record<number, string> = {
[terms.StateToken]: 'state',
[terms.TypeToken]: 'type',
[terms.HealthToken]: 'health',
[terms.DashboardToken]: 'dashboard',
};
// This enum allows to configure parser behavior
@ -27,6 +28,7 @@ export enum FilterSupportedTerm {
state = 'stateFilter',
type = 'typeFilter',
health = 'healthFilter',
dashboard = 'dashboardFilter',
}
export type QueryFilterMapper = Record<number, (filter: string) => void>;

View File

@ -189,7 +189,7 @@ export function fetchPromAndRulerRulesAction({
limitAlerts?: number;
matcher?: Matcher[];
state?: string[];
}): ThunkResult<void> {
}): ThunkResult<Promise<void>> {
return async (dispatch, getState) => {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName }));
const dsConfig = getDataSourceConfig(getState, rulesSourceName);