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:
@@ -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();
|
||||
|
||||
+75
@@ -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]);
|
||||
}
|
||||
+93
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+44
@@ -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}
|
||||
|
||||
+4
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user