Alerting: Analyze an alert rule with Grafana Assistant (#114420)

* fix

* rename to analyze

* Enable Analyze rule for GMA recording rules

* Fix declare incident button condition

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Sonia Aguilar
2025-11-26 13:51:31 +00:00
committed by GitHub
co-authored by Konrad Lalik
parent 9606e9c51c
commit 84fbe6bc7b
15 changed files with 265 additions and 4 deletions
@@ -28,6 +28,9 @@ import { Annotation } from './utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(alertingAbilities, 'useAlertRuleAbility');
const prometheusModuleSettings = { alerting: true, module: 'core:plugin/prometheus' };
@@ -45,6 +45,9 @@ jest.mock('@grafana/runtime', () => ({
jest.mock('./api/buildInfo');
jest.mock('./api/prometheus');
jest.mock('./api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
jest.spyOn(apiRuler, 'rulerUrlBuilder');
@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { OpenAssistantProps, createAssistantContextItem, useAssistant } from '@grafana/assistant';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Menu } from '@grafana/ui';
import { GrafanaAlertingRule, GrafanaRecordingRule, GrafanaRule } from 'app/types/unified-alerting';
import { prometheusRuleType } from '../../utils/rules';
interface AnalyzeRuleButtonProps {
/** Alert rule to analyze */
rule: GrafanaRule;
}
/**
* A menu item component that analyze an alert rule.
* Automatically creates context from alert data and opens the assistant in assistant mode.
*/
export function AnalyzeRuleButton(props: AnalyzeRuleButtonProps) {
const { isAvailable, openAssistant } = useAssistant();
if (!isAvailable || !openAssistant) {
return null;
}
return <AnalyzeRuleButtonView {...props} openAssistant={openAssistant} />;
}
function AnalyzeRuleButtonView({
rule,
openAssistant,
}: AnalyzeRuleButtonProps & {
openAssistant: (props: OpenAssistantProps) => void;
}) {
// Create alert rule context from alert rule data
const alertContext = useMemo(() => {
return createAssistantContextItem('structured', {
title: `Alert: ${rule.name}`,
data: {
rule: {
name: rule.name,
uid: rule.uid,
labels: rule.labels,
query: rule.query,
},
},
});
}, [rule]);
// Generate default prompt
const analyzeRulePrompt = useMemo(() => buildAnalyzeRulePrompt(rule), [rule]);
const handleClick = () => {
reportInteraction('grafana_assistant_app_analyze_rule_button_clicked', {
origin: 'alerting',
alertName: rule.name,
alertState: prometheusRuleType.grafana.alertingRule(rule) ? rule.state : undefined,
});
openAssistant({
origin: 'alerting',
mode: 'assistant',
prompt: analyzeRulePrompt,
context: [alertContext],
autoSend: true,
});
};
return (
<Menu.Item
label={t('alerting.alert-menu.analyze-rule', 'Analyze rule')}
icon="ai-sparkle"
onClick={handleClick}
data-testid="analyze-rule-menu-item"
/>
);
}
/**
* Builds a prompt for analyzing a rule (alerting or recording).
* Automatically detects the rule type and uses the appropriate prompt builder.
*/
function buildAnalyzeRulePrompt(rule: GrafanaRule): string {
if (prometheusRuleType.grafana.alertingRule(rule)) {
return buildAnalyzeAlertingRulePrompt(rule);
} else if (prometheusRuleType.grafana.recordingRule(rule)) {
return buildAnalyzeRecordingRulePrompt(rule);
}
// Fallback (should not happen for GrafanaRule, but TypeScript requires it)
return `Analyze the rule "${rule.name}".`;
}
/**
* Builds a prompt for analyzing an alerting rule.
* Includes state, activeAt timestamp, annotations, and labels.
*/
function buildAnalyzeAlertingRulePrompt(rule: GrafanaAlertingRule): string {
const state = rule.state || 'firing';
const timeInfo = rule.activeAt ? ` starting at ${new Date(rule.activeAt).toISOString()}` : '';
let prompt = `Analyze the ${state} alert "${rule.name}"${timeInfo}.`;
const description = rule.annotations?.description || rule.annotations?.summary || '';
if (description) {
prompt += ` ${description}`;
}
const labelsStr = rule.labels
? Object.entries(rule.labels)
.map(([k, v]) => `${k}="${v}"`)
.join(', ')
: '';
if (labelsStr) {
prompt += ` Labels: ${labelsStr}.`;
}
return prompt;
}
/**
* Builds a prompt for analyzing a recording rule.
* Includes name, query, and labels (no state or activeAt).
*/
function buildAnalyzeRecordingRulePrompt(rule: GrafanaRecordingRule): string {
const labelsStr = rule.labels
? Object.entries(rule.labels)
.map(([k, v]) => `${k}="${v}"`)
.join(', ')
: '';
let prompt = `Analyze the recording rule "${rule.name}".`;
if (labelsStr) {
prompt += ` Labels: ${labelsStr}.`;
}
return prompt;
}
@@ -1,5 +1,6 @@
import { PropsOf } from '@emotion/react';
import { useAssistant } from '@grafana/assistant';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
@@ -29,6 +30,7 @@ import {
rulerRuleType,
} from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { AnalyzeRuleButton } from '../assistant/AnalizeRuleButton';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
interface Props {
@@ -126,6 +128,9 @@ const AlertRuleMenu = ({
prometheusRuleType.alertingRule(promRule) &&
promRule.state === PromAlertingRuleState.Firing;
const { isAvailable: isAssistantAvailable } = useAssistant();
const shouldShowAnalyzeRuleButton = isAssistantAvailable && prometheusRuleType.grafana.rule(promRule);
const shareUrl = createShareLink(identifier);
const showDivider =
@@ -172,6 +177,7 @@ const AlertRuleMenu = ({
)}
{/* TODO Migrate Declare Incident to plugin links extensions */}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={promRule.name} url={''} />}
{shouldShowAnalyzeRuleButton && <AnalyzeRuleButton rule={promRule} />}
{canDuplicate && (
<Menu.Item
label={t('alerting.alert-menu.duplicate', 'Duplicate')}
@@ -36,6 +36,10 @@ import { AlertRuleProvider } from './RuleContext';
import RuleViewer, { ActiveTab } from './RuleViewer';
import { addRulePageEnrichmentSection } from './tabs/extensions/RuleViewerExtension';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
// metadata and interactive elements
const ELEMENTS = {
loading: byText(/Loading rule/i),
@@ -21,6 +21,10 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources';
import { fromCombinedRule, stringifyIdentifier } from '../../utils/rule-id';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setupMswServer();
jest.mock('app/core/services/context_srv');
const mockContextSrv = jest.mocked(contextSrv);
@@ -15,6 +15,10 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { RuleListGroupView } from './RuleListGroupView';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(analytics, 'logInfo');
const ui = {
@@ -10,6 +10,10 @@ import {
} from 'app/features/alerting/unified/mocks';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({
links: [],
isLoading: false,
@@ -18,6 +18,10 @@ import { mimirDataSource } from '../../mocks/server/configure';
import { RulesTable } from './RulesTable';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.mock('../../hooks/useAbilities');
const mocks = {
@@ -23,6 +23,10 @@ import { alertingFactory } from '../mocks/server/db';
import GroupDetailsPage from './GroupDetailsPage';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
@@ -17,6 +17,10 @@ import { fromRulerRuleAndGroupIdentifierV2 } from '../utils/rule-id';
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
import { createViewLinkFromIdentifier } from './DataSourceRuleListItem';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -12,6 +12,10 @@ import { RulesFilter } from '../search/rulesSearchParser';
import { FilterView } from './FilterView';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -1,6 +1,7 @@
import { render } from 'test/test-utils';
import { byLabelText, byRole } from 'testing-library-selector';
import { useAssistant } from '@grafana/assistant';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types/accessControl';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
@@ -22,6 +23,12 @@ import { intervalToSeconds } from '../utils/time';
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn(),
createAssistantContextItem: jest.fn((type, data) => ({ type, ...data })),
}));
const mockUseAssistant = jest.mocked(useAssistant);
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -41,11 +48,18 @@ const ui = {
export: () => byRole('menuitem', { name: /export/i }),
delete: () => byRole('menuitem', { name: /delete/i }),
pause: () => byRole('menuitem', { name: /pause/i }),
analyzeRule: () => byRole('menuitem', { name: /analyze rule/i }),
},
};
describe('GrafanaGroupLoader', () => {
beforeEach(() => {
mockUseAssistant.mockReturnValue({
isAvailable: false,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
grantUserPermissions([
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
@@ -213,6 +227,68 @@ describe('GrafanaGroupLoader', () => {
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(6);
});
it('should render Analyze rule menu item when assistant is available', async () => {
mockUseAssistant.mockReturnValue({
isAvailable: true,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
const { user } = render(
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />
);
const [rule1] = grafanaRulerGroup.rules;
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
// Click the More button to open the menu
const moreButton = ui.moreButton().get(ruleListItem);
await user.click(moreButton);
// Check that Analyze rule menu item is present
expect(ui.menuItems.analyzeRule().get()).toBeInTheDocument();
// With assistant enabled, there should be 7 menu items (6 + Analyze rule)
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(7);
});
it('should not render Analyze rule menu item when assistant is not available', async () => {
mockUseAssistant.mockReturnValue({
isAvailable: false,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
const { user } = render(
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />
);
const [rule1] = grafanaRulerGroup.rules;
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
// Click the More button to open the menu
const moreButton = ui.moreButton().get(ruleListItem);
await user.click(moreButton);
// Check that Analyze rule menu item is NOT present
expect(ui.menuItems.analyzeRule().query()).not.toBeInTheDocument();
// Without assistant, there should be 6 menu items
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(6);
});
});
function rulerGroupToPromGroup(group: RulerRuleGroupDTO<RulerGrafanaRuleDTO>): GrafanaPromRuleGroupDTO {
@@ -40,14 +40,15 @@ import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataA
jest.mock('app/features/alerting/unified/api/prometheus');
jest.mock('app/features/alerting/unified/api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
jest.spyOn(ruler, 'rulerUrlBuilder');
jest.spyOn(alertingAbilities, 'useAlertRuleAbility');
setPluginLinksHook(() => ({
links: [],
isLoading: false,
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
const dataSources = {
prometheus: mockDataSource<PromOptions>(
+1
View File
@@ -477,6 +477,7 @@
"noOptionsMessage-no-datasources-found": "No datasources found"
},
"alert-menu": {
"analyze-rule": "Analyze rule",
"copy-link": "Copy link",
"duplicate": "Duplicate",
"export": "Export",