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 (
+
+ );
+}
+
+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();