diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index f541c56f571..6305a789fab 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -117,6 +117,7 @@ export type PluginExtensionEventHelpers = { // 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', diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 2f072caf3d0..b48e7ef19a8 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -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(() => { diff --git a/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx new file mode 100644 index 00000000000..760c25fbca4 --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx @@ -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(); + const context = { instance, rule }; + const extensions = useExtensionLinks(context, extensionPointId); + + if (extensions.length === 0) { + return null; + } + + const menu = ; + return ( + <> + + + + {!!selectedExtension && !!selectedExtension.path && ( + 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]); +} diff --git a/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPointMenu.tsx b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPointMenu.tsx new file mode 100644 index 00000000000..e9f270d7129 --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPointMenu.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx index 1686ce6404a..e6769cafb4b 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx @@ -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', () => { diff --git a/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx b/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx index 4096e0e1792..d701a62f0ee 100644 --- a/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx @@ -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; } -type AlertTableColumnProps = DynamicTableColumnProps; -type AlertTableItemProps = DynamicTableItemProps; +interface RuleAndAlert { + rule?: CombinedRule; + alert: AlertWithCommonLabels; +} + +type AlertTableColumnProps = DynamicTableColumnProps; +type AlertTableItemProps = DynamicTableItemProps; -export const AlertInstancesTable = ({ instances, pagination, footerRow }: Props) => { +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 }) => } + renderExpandedContent={({ data }) => } 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 } }) => , + renderCell: ({ + data: { + alert: { state }, + }, + }) => , size: '80px', }, { id: 'labels', label: 'Labels', // eslint-disable-next-line react/display-name - renderCell: ({ data: { labels, commonLabels } }) => ( - - ), + renderCell: ({ + data: { + alert: { labels, commonLabels }, + }, + }) => , }, { 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 } }) => ( + + ), + size: '40px', + }, ]; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index d8b07efe7dc..53caeeeccea 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -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(); }); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx index 774f2ea7c79..d9bfcb6b6e2 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx @@ -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(); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index 76add623041..042b710db2b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -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(); const [alertState, setAlertState] = useState(); @@ -129,7 +125,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { )} {!enableFiltering &&
{statsComponents}
} - + ); } diff --git a/public/app/plugins/panel/alertlist/AlertInstances.tsx b/public/app/plugins/panel/alertlist/AlertInstances.tsx index 85c40fd22b3..c7b289690c6 100644 --- a/public/app/plugins/panel/alertlist/AlertInstances.tsx +++ b/public/app/plugins/panel/alertlist/AlertInstances.tsx @@ -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['options']; grafanaTotalInstances?: number; @@ -25,6 +26,7 @@ interface Props { } export const AlertInstances = ({ + rule, alerts, options, grafanaTotalInstances, @@ -125,6 +127,7 @@ export const AlertInstances = ({ )} {displayInstances && ( ({ + ...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 = { diff --git a/public/app/plugins/panel/alertlist/types.ts b/public/app/plugins/panel/alertlist/types.ts index a0e07fd4516..b128b631b66 100644 --- a/public/app/plugins/panel/alertlist/types.ts +++ b/public/app/plugins/panel/alertlist/types.ts @@ -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; diff --git a/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx b/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx index cdc28794cbf..fbdb1b71db6 100644 --- a/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx +++ b/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx @@ -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(() => { - const groupedRules = new Map(); + const groupedRules = useMemo>(() => { + const groupedRules = new Map(); 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); - } - - return acc; - }, new Map()); + 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() + ); return filteredGroupedRules; }, [groupBy, rules, options]); return ( <> - {Array.from(groupedRules).map(([key, alerts]) => ( + {Array.from(groupedRules).map(([key, { rule, alerts }]) => (
  • @@ -71,7 +79,7 @@ const GroupedModeView = ({ rules, options }: Props) => { {key === UNGROUPED_KEY && 'No grouping'}
    - +
  • ))} diff --git a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx index 935e4526762..bfa92a15ec0 100644 --- a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx +++ b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx @@ -119,6 +119,7 @@ const UngroupedModeView = ({ rules, options, handleInstancesLimit, limitInstance