Alerting: Add extension point link from alert rule to grafana-metricsdrilldown-app (#108566)

* add alerting rule query editor extension point

* add extension for queryless apps, allow for multiple extensions

* add button to querywrapper

* remove debugger

* add hook to get tests to pass

* fix spelling of component

* fix tests
This commit is contained in:
Brendan O'Handley
2025-08-06 11:01:58 -05:00
committed by GitHub
parent 5ad751ea28
commit f49f7d9327
10 changed files with 277 additions and 0 deletions
@@ -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',
@@ -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 }) => <div>{actions}</div>,
@@ -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();
@@ -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 (
<Menu>
<>
{Object.keys(categorised).map((category) => (
<Menu.Group key={category} label={category}>
{renderItems(categorised[category], onSelect)}
</Menu.Group>
))}
{showDivider && <Menu.Divider key="divider" />}
{renderItems(uncategorised, onSelect)}
</>
</Menu>
);
}
function renderItems(extensions: PluginExtensionLink[], onSelect: (link: PluginExtensionLink) => void): JSX.Element[] {
return extensions.map((extension) => (
<Menu.Item
ariaLabel={extension.title}
icon={extension?.icon || 'plug'}
key={extension.id}
label={extension.title}
onClick={(event) => {
if (extension.path) {
return onSelect(extension);
}
extension.onClick?.(event);
}}
/>
));
}
type ExtensionLinksResult = {
uncategorised: PluginExtensionLink[];
categorised: Record<string, PluginExtensionLink[]>;
};
function useExtensionLinksByCategory(extensions: PluginExtensionLink[]): ExtensionLinksResult {
return useMemo(() => {
const uncategorised: PluginExtensionLink[] = [];
const categorised: Record<string, PluginExtensionLink[]> = {};
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]);
}
@@ -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<string, string[]> = {
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<PluginExtensionLink | undefined>();
const [isModalOpen, setIsModalOpen] = useState<boolean>(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' && (
<QuerylessAppsExtensions
links={querylessLinks}
setSelectedExtension={(extension) => {
setSelectedExtension(extension);
}}
setIsModalOpen={setIsModalOpen}
isModalOpen={isModalOpen}
/>
)}
{/* TODO: add basic extensions */}
{!!selectedExtension && !!selectedExtension.path && (
<ConfirmNavigationModal
path={selectedExtension.path}
title={selectedExtension.title}
onDismiss={() => setSelectedExtension(undefined)}
/>
)}
</>
);
}
@@ -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 (
<Modal title={title} isOpen onDismiss={onDismiss}>
<Stack direction="column" gap={1}>
<p>
<Trans i18nKey="explore.confirm-navigation-modal.new-tab">
Do you want to proceed in the current tab or open a new tab?
</Trans>
</p>
</Stack>
<Modal.ButtonRow>
<Button onClick={onDismiss} fill="outline" variant="secondary">
<Trans i18nKey="explore.confirm-navigation-modal.cancel">Cancel</Trans>
</Button>
<Button type="submit" variant="secondary" onClick={openInNewTab} icon="external-link-alt">
<Trans i18nKey="explore.confirm-navigation-modal.open-in-new-tab">Open in new tab</Trans>
</Button>
<Button type="submit" variant="primary" onClick={openInCurrentTab} icon="apps">
<Trans i18nKey="explore.confirm-navigation-modal.open">Open</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
);
}
@@ -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 = <AlertingRuleExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
if (links.length === 1) {
const link = first(links)!;
return (
<ToolbarButton variant="canvas" icon={link.icon} onClick={() => setSelectedExtension(link)}>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
);
}
return (
<Dropdown onVisibleChange={setIsModalOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton
aria-label={t('explore.queryless-apps-extensions.aria-label-go-queryless', 'Go queryless')}
variant="canvas"
isOpen={isModalOpen}
>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
</Dropdown>
);
}
@@ -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 (
<Stack direction="row" alignItems="center" gap={1}>
<SelectingDataSourceTooltip />
<AlertingRuleQueryExtensionPoint query={Object.assign({}, query.model)} extensionsToShow="queryless" />
<QueryOptions
onChangeTimeRange={onChangeTimeRange}
query={query}
@@ -5,6 +5,7 @@ import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { screen, waitFor, within } 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 { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
@@ -81,6 +82,9 @@ beforeEach(() => {
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();
@@ -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([
@@ -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();