Alerting: Add actions extension point to alert instances table view (#77900)

This PR adds a new [extension point][] to each row of the
alert instances table. This allows plugins to add actions
to a dropdown menu in the rightmost column of the table.
Those actions are passed the alert instance so can use it
for contextual handling.

See https://github.com/grafana/machine-learning/pull/3461
for an example of how this can be used (e.g. by Grafana Sift
here).
This commit is contained in:
Ben Sully 2023-11-20 16:43:36 +00:00 committed by GitHub
parent 259ecb1793
commit 35b1e49024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 261 additions and 48 deletions

View File

@ -117,6 +117,7 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
// Extension Points available in core Grafana
export enum PluginExtensionPoints {
AlertInstanceAction = 'grafana/alerting/instance/action',
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
DataSourceConfig = 'grafana/datasources/config',
ExploreToolbarAction = 'grafana/explore/toolbar/action',

View File

@ -5,7 +5,14 @@ import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { DataSourceSrv, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { PluginExtensionTypes } from '@grafana/data';
import {
DataSourceSrv,
getPluginLinkExtensions,
locationService,
setBackendSrv,
setDataSourceSrv,
} from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import * as actions from 'app/features/alerting/unified/state/actions';
@ -32,6 +39,10 @@ import {
import * as config from './utils/config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('./api/buildInfo');
jest.mock('./api/prometheus');
jest.mock('./api/ruler');
@ -53,6 +64,7 @@ jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
const mocks = {
getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor),
api: {
@ -137,6 +149,19 @@ describe('RuleList', () => {
AccessControlAction.AlertingRuleExternalWrite,
]);
mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]);
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
});
afterEach(() => {

View File

@ -0,0 +1,65 @@
import React, { ReactElement, useMemo, useState } from 'react';
import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { Dropdown, IconButton } from '@grafana/ui';
import { ConfirmNavigationModal } from 'app/features/explore/extensions/ConfirmNavigationModal';
import { Alert, CombinedRule } from 'app/types/unified-alerting';
import { AlertExtensionPointMenu } from './AlertInstanceExtensionPointMenu';
interface AlertInstanceExtensionPointProps {
rule?: CombinedRule;
instance: Alert;
extensionPointId: PluginExtensionPoints;
}
export const AlertInstanceExtensionPoint = ({
rule,
instance,
extensionPointId,
}: AlertInstanceExtensionPointProps): ReactElement | null => {
const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>();
const context = { instance, rule };
const extensions = useExtensionLinks(context, extensionPointId);
if (extensions.length === 0) {
return null;
}
const menu = <AlertExtensionPointMenu extensions={extensions} onSelect={setSelectedExtension} />;
return (
<>
<Dropdown placement="bottom-start" overlay={menu}>
<IconButton name="ellipsis-v" aria-label="Actions" variant="secondary" />
</Dropdown>
{!!selectedExtension && !!selectedExtension.path && (
<ConfirmNavigationModal
path={selectedExtension.path}
title={selectedExtension.title}
onDismiss={() => setSelectedExtension(undefined)}
/>
)}
</>
);
};
export type PluginExtensionAlertInstanceContext = {
rule?: CombinedRule;
instance: Alert;
};
function useExtensionLinks(
context: PluginExtensionAlertInstanceContext,
extensionPointId: PluginExtensionPoints
): PluginExtensionLink[] {
return useMemo(() => {
const { extensions } = getPluginLinkExtensions({
extensionPointId,
context,
limitPerPlugin: 3,
});
return extensions;
}, [context, extensionPointId]);
}

View File

@ -0,0 +1,2 @@
// We might want to customise this in future but right now the toolbar menu from the Explore view is fine.
export { ToolbarExtensionPointMenu as AlertExtensionPointMenu } from 'app/features/explore/extensions/ToolbarExtensionPointMenu';

View File

@ -4,7 +4,8 @@ import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byText } from 'testing-library-selector';
import { locationService, setBackendSrv } from '@grafana/runtime';
import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, locationService, setBackendSrv } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
@ -48,10 +49,15 @@ const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourc
staticContext: {},
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('../../hooks/useIsRuleEditable');
jest.mock('../../api/buildInfo');
const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
useIsRuleEditable: jest.mocked(useIsRuleEditable),
};
@ -144,6 +150,19 @@ beforeEach(() => {
},
status: 'success',
});
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
});
describe('RuleViewer', () => {

View File

@ -1,16 +1,18 @@
import React, { useMemo } from 'react';
import { dateTime, findCommonLabels } from '@grafana/data';
import { Alert, PaginationProps } from 'app/types/unified-alerting';
import { PluginExtensionPoints, dateTime, findCommonLabels } from '@grafana/data';
import { Alert, CombinedRule, PaginationProps } from 'app/types/unified-alerting';
import { alertInstanceKey } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AlertInstanceExtensionPoint } from '../extensions/AlertInstanceExtensionPoint';
import { AlertInstanceDetails } from './AlertInstanceDetails';
import { AlertStateTag } from './AlertStateTag';
interface Props {
rule?: CombinedRule;
instances: Alert[];
pagination?: PaginationProps;
footerRow?: JSX.Element;
@ -20,10 +22,15 @@ interface AlertWithCommonLabels extends Alert {
commonLabels?: Record<string, string>;
}
type AlertTableColumnProps = DynamicTableColumnProps<AlertWithCommonLabels>;
type AlertTableItemProps = DynamicTableItemProps<AlertWithCommonLabels>;
interface RuleAndAlert {
rule?: CombinedRule;
alert: AlertWithCommonLabels;
}
export const AlertInstancesTable = ({ instances, pagination, footerRow }: Props) => {
type AlertTableColumnProps = DynamicTableColumnProps<RuleAndAlert>;
type AlertTableItemProps = DynamicTableItemProps<RuleAndAlert>;
export const AlertInstancesTable = ({ rule, instances, pagination, footerRow }: Props) => {
const commonLabels = useMemo(() => {
// only compute the common labels if we have more than 1 instance, if we don't then that single instance
// will have the complete set of common labels and no unique ones
@ -33,10 +40,10 @@ export const AlertInstancesTable = ({ instances, pagination, footerRow }: Props)
const items = useMemo(
(): AlertTableItemProps[] =>
instances.map((instance) => ({
data: { ...instance, commonLabels },
data: { rule, alert: { ...instance, commonLabels } },
id: alertInstanceKey(instance),
})),
[commonLabels, instances]
[commonLabels, instances, rule]
);
return (
@ -44,7 +51,7 @@ export const AlertInstancesTable = ({ instances, pagination, footerRow }: Props)
cols={columns}
isExpandable={true}
items={items}
renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data} />}
renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data.alert} />}
pagination={pagination}
footerRow={footerRow}
/>
@ -56,24 +63,45 @@ const columns: AlertTableColumnProps[] = [
id: 'state',
label: 'State',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { state } }) => <AlertStateTag state={state} />,
renderCell: ({
data: {
alert: { state },
},
}) => <AlertStateTag state={state} />,
size: '80px',
},
{
id: 'labels',
label: 'Labels',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { labels, commonLabels } }) => (
<AlertLabels labels={labels} commonLabels={commonLabels} size="sm" />
),
renderCell: ({
data: {
alert: { labels, commonLabels },
},
}) => <AlertLabels labels={labels} commonLabels={commonLabels} size="sm" />,
},
{
id: 'created',
label: 'Created',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { activeAt } }) => (
<>{activeAt.startsWith('0001') ? '-' : dateTime(activeAt).format('YYYY-MM-DD HH:mm:ss')}</>
),
renderCell: ({
data: {
alert: { activeAt },
},
}) => <>{activeAt.startsWith('0001') ? '-' : dateTime(activeAt).format('YYYY-MM-DD HH:mm:ss')}</>,
size: '150px',
},
{
id: 'actions',
label: '',
renderCell: ({ data: { alert, rule } }) => (
<AlertInstanceExtensionPoint
rule={rule}
instance={alert}
extensionPointId={PluginExtensionPoints.AlertInstanceAction}
key="alert-instance-extension-point"
/>
),
size: '40px',
},
];

View File

@ -5,7 +5,8 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { byRole } from 'testing-library-selector';
import { setBackendSrv } from '@grafana/runtime';
import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
@ -20,9 +21,15 @@ import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
import { RuleDetails } from './RuleDetails';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('../../hooks/useIsRuleEditable');
const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
useIsRuleEditable: jest.mocked(useIsRuleEditable),
};
@ -52,6 +59,19 @@ afterAll(() => {
});
beforeEach(() => {
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
server.resetHandlers();
});

View File

@ -4,6 +4,9 @@ import { times } from 'lodash';
import React from 'react';
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { GrafanaAlertState, PromAlertingRuleState } from '../../../../../types/unified-alerting-dto';
import { mockCombinedRule, mockDataSource, mockPromAlert, mockPromAlertingRule } from '../../mocks';
@ -11,6 +14,15 @@ import { alertStateToReadable } from '../../utils/rules';
import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
}));
const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
};
const ui = {
stateFilter: byTestId('alert-instance-state-filter'),
stateButton: byRole('radio'),
@ -30,6 +42,22 @@ const ui = {
};
describe('RuleDetailsMatchingInstances', () => {
beforeEach(() => {
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
});
describe('Filtering', () => {
it('For Grafana Managed rules instances filter should contain five states', () => {
const rule = mockCombinedRule();

View File

@ -53,12 +53,8 @@ function ShowMoreInstances(props: { onClick: () => void; stats: ShowMoreStats })
export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
const history = useHistory();
const {
rule: { promRule, namespace, instanceTotals },
itemsDisplayLimit = Number.POSITIVE_INFINITY,
pagination,
enableFiltering = false,
} = props;
const { rule, itemsDisplayLimit = Number.POSITIVE_INFINITY, pagination, enableFiltering = false } = props;
const { promRule, namespace, instanceTotals } = rule;
const [queryString, setQueryString] = useState<string>();
const [alertState, setAlertState] = useState<InstanceStateFilter>();
@ -129,7 +125,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
</div>
)}
{!enableFiltering && <div className={styles.stats}>{statsComponents}</div>}
<AlertInstancesTable instances={visibleInstances} pagination={pagination} footerRow={footerRow} />
<AlertInstancesTable rule={rule} instances={visibleInstances} pagination={pagination} footerRow={footerRow} />
</DetailsField>
);
}

View File

@ -8,7 +8,7 @@ import { Button, clearButtonStyles, Icon, useStyles2 } from '@grafana/ui';
import { AlertInstancesTable } from 'app/features/alerting/unified/components/rules/AlertInstancesTable';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
import { sortAlerts } from 'app/features/alerting/unified/utils/misc';
import { Alert } from 'app/types/unified-alerting';
import { Alert, CombinedRule } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
@ -16,6 +16,7 @@ import { GroupMode, UnifiedAlertListOptions } from './types';
import { filterAlerts } from './util';
interface Props {
rule?: CombinedRule;
alerts: Alert[];
options: PanelProps<UnifiedAlertListOptions>['options'];
grafanaTotalInstances?: number;
@ -25,6 +26,7 @@ interface Props {
}
export const AlertInstances = ({
rule,
alerts,
options,
grafanaTotalInstances,
@ -125,6 +127,7 @@ export const AlertInstances = ({
)}
{displayInstances && (
<AlertInstancesTable
rule={rule}
instances={filteredAlerts}
pagination={{ itemsPerPage: 2 * DEFAULT_PER_PAGE_PAGINATION }}
footerRow={footerRow}

View File

@ -5,8 +5,8 @@ import { Provider } from 'react-redux';
import { act } from 'react-test-renderer';
import { byRole, byText } from 'testing-library-selector';
import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps } from '@grafana/data';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, TimeRangeUpdatedEvent } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi';
import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi';
@ -56,8 +56,16 @@ const grafanaRuleMock = {
},
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('app/features/alerting/unified/api/alertmanager');
const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
};
const fakeResponse: PromRulesResponse = {
data: { groups: grafanaRuleMock.promRules.grafana.result[0].groups as PromRuleGroupDTO[] },
status: 'success',
@ -78,6 +86,19 @@ beforeEach(() => {
mockRulerRulesApiResponse(server, 'grafana', {
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
});
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
});
const defaultOptions: UnifiedAlertListOptions = {

View File

@ -1,5 +1,3 @@
import { Alert } from 'app/types/unified-alerting';
export enum SortOrder {
AlphaAsc = 1,
AlphaDesc,
@ -65,5 +63,3 @@ export interface UnifiedAlertListOptions {
datasource: string;
viewMode: ViewMode;
}
export type GroupedRules = Map<string, Alert[]>;

View File

@ -8,7 +8,7 @@ import { Alert } from 'app/types/unified-alerting';
import { CombinedRuleWithLocation } from '../../../../types/unified-alerting';
import { AlertInstances } from '../AlertInstances';
import { getStyles } from '../UnifiedAlertList';
import { GroupedRules, UnifiedAlertListOptions } from '../types';
import { UnifiedAlertListOptions } from '../types';
import { filterAlerts } from '../util';
type Props = {
@ -16,14 +16,19 @@ type Props = {
options: UnifiedAlertListOptions;
};
type RuleWithAlerts = {
rule?: CombinedRuleWithLocation;
alerts: Alert[];
};
export const UNGROUPED_KEY = '__ungrouped__';
const GroupedModeView = ({ rules, options }: Props) => {
const styles = useStyles2(getStyles);
const groupBy = options.groupBy;
const groupedRules = useMemo<GroupedRules>(() => {
const groupedRules = new Map<string, Alert[]>();
const groupedRules = useMemo<Map<string, RuleWithAlerts>>(() => {
const groupedRules = new Map<string, RuleWithAlerts>();
const hasInstancesWithMatchingLabels = (rule: CombinedRuleWithLocation) =>
groupBy ? alertHasEveryLabelForCombinedRules(rule, groupBy) : true;
@ -35,33 +40,36 @@ const GroupedModeView = ({ rules, options }: Props) => {
(alertingRule?.alerts ?? []).forEach((alert) => {
const mapKey = hasInstancesMatching ? createMapKey(groupBy, alert.labels) : UNGROUPED_KEY;
const existingAlerts = groupedRules.get(mapKey) ?? [];
groupedRules.set(mapKey, [...existingAlerts, alert]);
const existingAlerts = groupedRules.get(mapKey)?.alerts ?? [];
groupedRules.set(mapKey, { rule, alerts: [...existingAlerts, alert] });
});
});
// move the "UNGROUPED" key to the last item in the Map, items are shown in insertion order
const ungrouped = groupedRules.get(UNGROUPED_KEY) ?? [];
const ungrouped = groupedRules.get(UNGROUPED_KEY)?.alerts ?? [];
groupedRules.delete(UNGROUPED_KEY);
groupedRules.set(UNGROUPED_KEY, ungrouped);
groupedRules.set(UNGROUPED_KEY, { alerts: ungrouped });
// Remove groups having no instances
// This is different from filtering Rules without instances that we do in UnifiedAlertList
const filteredGroupedRules = Array.from(groupedRules.entries()).reduce((acc, [groupKey, groupAlerts]) => {
const filteredAlerts = filterAlerts(options, groupAlerts);
if (filteredAlerts.length > 0) {
acc.set(groupKey, filteredAlerts);
}
const filteredGroupedRules = Array.from(groupedRules.entries()).reduce(
(acc, [groupKey, { rule, alerts: groupAlerts }]) => {
const filteredAlerts = filterAlerts(options, groupAlerts);
if (filteredAlerts.length > 0) {
acc.set(groupKey, { rule, alerts: filteredAlerts });
}
return acc;
}, new Map<string, Alert[]>());
return acc;
},
new Map<string, RuleWithAlerts>()
);
return filteredGroupedRules;
}, [groupBy, rules, options]);
return (
<>
{Array.from(groupedRules).map(([key, alerts]) => (
{Array.from(groupedRules).map(([key, { rule, alerts }]) => (
<li className={styles.alertRuleItem} key={key} data-testid={key}>
<div>
<div className={styles.customGroupDetails}>
@ -71,7 +79,7 @@ const GroupedModeView = ({ rules, options }: Props) => {
{key === UNGROUPED_KEY && 'No grouping'}
</div>
</div>
<AlertInstances alerts={alerts} options={options} />
<AlertInstances rule={rule} alerts={alerts} options={options} />
</div>
</li>
))}

View File

@ -119,6 +119,7 @@ const UngroupedModeView = ({ rules, options, handleInstancesLimit, limitInstance
</div>
</div>
<AlertInstances
rule={ruleWithLocation}
alerts={alertingRule.alerts ?? []}
options={options}
grafanaTotalInstances={grafanaInstancesTotal}