diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 488eced154a..3ed7de0c766 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -190,6 +190,7 @@ export enum PluginExtensionPoints { AlertingHomePage = 'grafana/alerting/home', AlertingAlertingRuleAction = 'grafana/alerting/alertingrule/action', AlertingRecordingRuleAction = 'grafana/alerting/recordingrule/action', + AlertingRuleQueryEditor = 'grafana/alerting/alertingrule/queryeditor', CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx index fcc74629ca1..f026e8f3f46 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx @@ -17,6 +17,7 @@ import { captureRequests, serializeRequests } from './mocks/server/events'; import { FOLDER_TITLE_HAPPY_PATH } from './mocks/server/handlers/search'; import { testWithFeatureToggles } from './test/test-utils'; import { setupDataSources } from './testSetup/datasources'; +import { setupPluginsExtensionsHook } from './testSetup/plugins'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, @@ -45,6 +46,10 @@ const dataSources = { { alerting: true, module: 'core:plugin/prometheus' } ), }; + +// Setup plugin extensions hook to prevent setPluginLinksHook errors +setupPluginsExtensionsHook(); + describe('RuleEditor grafana recording rules', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx new file mode 100644 index 00000000000..20ef95fdb22 --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx @@ -0,0 +1,75 @@ +import { ReactElement, useMemo } from 'react'; + +import { PluginExtensionLink } from '@grafana/data'; +import { Menu } from '@grafana/ui'; + +type Props = { + extensions: PluginExtensionLink[]; + onSelect: (extension: PluginExtensionLink) => void; +}; + +export function AlertingRuleExtensionPointMenu({ extensions, onSelect }: Props): ReactElement | null { + const { categorised, uncategorised } = useExtensionLinksByCategory(extensions); + const showDivider = uncategorised.length > 0 && Object.keys(categorised).length > 0; + + return ( + + <> + {Object.keys(categorised).map((category) => ( + + {renderItems(categorised[category], onSelect)} + + ))} + {showDivider && } + {renderItems(uncategorised, onSelect)} + + + ); +} + +function renderItems(extensions: PluginExtensionLink[], onSelect: (link: PluginExtensionLink) => void): JSX.Element[] { + return extensions.map((extension) => ( + { + if (extension.path) { + return onSelect(extension); + } + extension.onClick?.(event); + }} + /> + )); +} + +type ExtensionLinksResult = { + uncategorised: PluginExtensionLink[]; + categorised: Record; +}; + +function useExtensionLinksByCategory(extensions: PluginExtensionLink[]): ExtensionLinksResult { + return useMemo(() => { + const uncategorised: PluginExtensionLink[] = []; + const categorised: Record = {}; + + for (const link of extensions) { + if (!link.category) { + uncategorised.push(link); + continue; + } + + if (!Array.isArray(categorised[link.category])) { + categorised[link.category] = []; + } + categorised[link.category].push(link); + continue; + } + + return { + uncategorised, + categorised, + }; + }, [extensions]); +} diff --git a/public/app/features/alerting/unified/components/extensions/AlertingRuleQueryExtensionPoint.tsx b/public/app/features/alerting/unified/components/extensions/AlertingRuleQueryExtensionPoint.tsx new file mode 100644 index 00000000000..1a8c524e6b8 --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/AlertingRuleQueryExtensionPoint.tsx @@ -0,0 +1,93 @@ +import { ReactElement, useState } from 'react'; + +import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data'; +// import { Trans, t } from '@grafana/i18n'; +import { usePluginLinks } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +// import { Button } from '@grafana/ui'; + +import { ConfirmNavigationModal } from './ConfirmationNavigationModal'; +import { QuerylessAppsExtensions } from './QuerylessAppExtensions'; + +type Props = { + extensionsToShow: 'queryless'; + query: DataQuery; +}; + +const QUERYLESS_APPS = [ + 'grafana-pyroscope-app', + 'grafana-lokiexplore-app', + 'grafana-exploretraces-app', + 'grafana-metricsdrilldown-app', +]; + +// Map data source types to compatible queryless apps +const DATASOURCE_TO_QUERYLESS_APP: Record = { + prometheus: ['grafana-metricsdrilldown-app'], + // todo: add more data source types here + // 'pyroscope': ['grafana-pyroscope-app'], + // 'loki': ['grafana-lokiexplore-app'], + // 'tempo': ['grafana-exploretraces-app'], +}; + +export type PluginExtensionAlertingRuleContext = { + targets: DataQuery[]; + // TODO: add rule form values for creating alerting rule from drilldown apps +}; + +export function AlertingRuleQueryExtensionPoint({ extensionsToShow, query }: Props): ReactElement | null { + const [selectedExtension, setSelectedExtension] = useState(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const context: PluginExtensionAlertingRuleContext = { + targets: [query], + }; + + const { links } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.AlertingRuleQueryEditor, + context: context, + limitPerPlugin: 3, + }); + + // filter the link so that the query data source matches the queryless app data source + // we only want one link per query row editor for now + // but we can show an array of links for more flexibility in the future + const querylessLinks = links.filter((link) => { + if (!QUERYLESS_APPS.includes(link.pluginId)) { + return false; + } + + // Get the data source type from the query + const datasourceType = query.datasource?.type; + if (!datasourceType) { + return false; + } + + // Check if this queryless app is compatible with the data source type + const compatibleApps = DATASOURCE_TO_QUERYLESS_APP[datasourceType.toLowerCase()] || []; + return compatibleApps.includes(link.pluginId); + }); + + return ( + <> + {extensionsToShow === 'queryless' && ( + { + setSelectedExtension(extension); + }} + setIsModalOpen={setIsModalOpen} + isModalOpen={isModalOpen} + /> + )} + {/* TODO: add basic extensions */} + {!!selectedExtension && !!selectedExtension.path && ( + setSelectedExtension(undefined)} + /> + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/extensions/ConfirmationNavigationModal.tsx b/public/app/features/alerting/unified/components/extensions/ConfirmationNavigationModal.tsx new file mode 100644 index 00000000000..297ebff224e --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/ConfirmationNavigationModal.tsx @@ -0,0 +1,44 @@ +import { ReactElement } from 'react'; + +import { locationUtil } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { locationService } from '@grafana/runtime'; +import { Button, Modal, Stack } from '@grafana/ui'; + +type Props = { + onDismiss: () => void; + path: string; + title: string; +}; + +export function ConfirmNavigationModal(props: Props): ReactElement { + const { onDismiss, path, title } = props; + const openInNewTab = () => { + global.open(locationUtil.assureBaseUrl(path), '_blank'); + onDismiss(); + }; + const openInCurrentTab = () => locationService.push(path); + + return ( + + +

+ + Do you want to proceed in the current tab or open a new tab? + +

+
+ + + + + +
+ ); +} diff --git a/public/app/features/alerting/unified/components/extensions/QuerylessAppExtensions.tsx b/public/app/features/alerting/unified/components/extensions/QuerylessAppExtensions.tsx new file mode 100644 index 00000000000..6aa929523d4 --- /dev/null +++ b/public/app/features/alerting/unified/components/extensions/QuerylessAppExtensions.tsx @@ -0,0 +1,45 @@ +import { first } from 'lodash'; + +import { PluginExtensionLink } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Dropdown, ToolbarButton } from '@grafana/ui'; + +import { AlertingRuleExtensionPointMenu } from './AlertingRuleExtensionPointMenu'; + +export type ExtensionDropdownProps = { + links: PluginExtensionLink[]; + setSelectedExtension: (extension: PluginExtensionLink) => void; + setIsModalOpen: (value: boolean) => void; + isModalOpen: boolean; +}; + +export function QuerylessAppsExtensions(props: ExtensionDropdownProps) { + const { links, setSelectedExtension, setIsModalOpen, isModalOpen } = props; + + if (links.length === 0) { + return undefined; + } + + const menu = ; + + if (links.length === 1) { + const link = first(links)!; + return ( + setSelectedExtension(link)}> + Go queryless + + ); + } + + return ( + + + Go queryless + + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 2e7e09e0a5e..65728cd922d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -27,6 +27,7 @@ import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { RuleFormValues } from '../../types/rule-form'; import { msToSingleUnitDuration } from '../../utils/time'; import { ExpressionStatusIndicator } from '../expressions/ExpressionStatusIndicator'; +import { AlertingRuleQueryExtensionPoint } from '../extensions/AlertingRuleQueryExtensionPoint'; import { QueryOptions } from './QueryOptions'; import { VizWrapper } from './VizWrapper'; @@ -169,6 +170,7 @@ export const QueryWrapper = ({ return ( + { setupMswServer(); setupDataSources(dataSources.default, dataSources.am); +// Setup plugin extensions hook to prevent setPluginLinksHook errors +setPluginLinksHook(() => ({ links: [], isLoading: false })); + describe('Can create a new grafana managed alert using simplified routing', () => { beforeEach(() => { window.localStorage.clear(); diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx index 7e823431f87..36da4810c9e 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx @@ -12,6 +12,7 @@ import { GROUP_3, GROUP_4, NAMESPACE_2 } from '../mocks/mimirRulerApi'; import { mimirDataSource } from '../mocks/server/configure'; import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants'; import { captureRequests, serializeRequests } from '../mocks/server/events'; +import { setupPluginsExtensionsHook } from '../testSetup/plugins'; jest.mock('../components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name @@ -27,6 +28,9 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ setupMswServer(); mimirDataSource(); +// Setup plugin extensions hook to prevent setPluginLinksHook errors +setupPluginsExtensionsHook(); + describe('RuleEditor cloud', () => { beforeEach(() => { grantUserPermissions([ diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index 33e141b05c9..1e8d537b3b7 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -4,6 +4,7 @@ import { clickSelectOption, selectOptionInTest } from 'test/helpers/selectOption import { screen, waitFor } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; +import { setPluginLinksHook } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; @@ -40,6 +41,9 @@ const dataSources = { setupDataSources(dataSources.default); +// Setup plugin extensions hook to prevent setPluginLinksHook errors +setPluginLinksHook(() => ({ links: [], isLoading: false })); + describe('RuleEditor grafana managed rules', () => { beforeEach(() => { jest.clearAllMocks();