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:
co-authored by
Konrad Lalik
parent
9606e9c51c
commit
84fbe6bc7b
@@ -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 {
|
||||
|
||||
+5
-4
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user