mirror of
https://github.com/grafana/grafana.git
synced 2025-12-24 05:44:14 +08:00
Compare commits
33 Commits
docs/add-t
...
alerting/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
625292750e | ||
|
|
cc31663b60 | ||
|
|
704df95ff3 | ||
|
|
2f24d9c0bd | ||
|
|
43fff90083 | ||
|
|
750dc0da7e | ||
|
|
7f2db9e714 | ||
|
|
c6fc5db7d5 | ||
|
|
967e59d436 | ||
|
|
840e3d4f16 | ||
|
|
62c7f8cf0f | ||
|
|
294ea6e2e2 | ||
|
|
145e28f3dc | ||
|
|
9a4ff7401a | ||
|
|
11046afc60 | ||
|
|
8c35fb22d8 | ||
|
|
20cc0e9b31 | ||
|
|
2d87411717 | ||
|
|
4dc547b65b | ||
|
|
9364c35e3e | ||
|
|
97a648d8dd | ||
|
|
7d96a5a701 | ||
|
|
0d6522d1dc | ||
|
|
6396402b04 | ||
|
|
0d8893ad20 | ||
|
|
30ae184497 | ||
|
|
88963e846f | ||
|
|
32ee91ebcd | ||
|
|
1f5d0f7d6d | ||
|
|
c335ad0571 | ||
|
|
5875109661 | ||
|
|
485ac51021 | ||
|
|
88bd3566c4 |
@@ -1858,11 +1858,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx": {
|
|
||||||
"@typescript-eslint/consistent-type-assertions": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx": {
|
"public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@@ -333,6 +333,10 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
alertingUIUseFullyCompatBackendFilters?: boolean;
|
alertingUIUseFullyCompatBackendFilters?: boolean;
|
||||||
/**
|
/**
|
||||||
|
* Enables creating alert rules from a panel using a drawer UI
|
||||||
|
*/
|
||||||
|
createAlertRuleFromPanel?: boolean;
|
||||||
|
/**
|
||||||
* Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
|
* Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
|
||||||
*/
|
*/
|
||||||
alertmanagerRemotePrimary?: boolean;
|
alertmanagerRemotePrimary?: boolean;
|
||||||
|
|||||||
@@ -534,6 +534,13 @@ var (
|
|||||||
Owner: grafanaAlertingSquad,
|
Owner: grafanaAlertingSquad,
|
||||||
HideFromDocs: true,
|
HideFromDocs: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "createAlertRuleFromPanel",
|
||||||
|
Description: "Enables creating alert rules from a panel using a drawer UI",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaAlertingSquad,
|
||||||
|
FrontendOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "alertmanagerRemotePrimary",
|
Name: "alertmanagerRemotePrimary",
|
||||||
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
|
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
|
||||||
|
|||||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -74,6 +74,7 @@ alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,fal
|
|||||||
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
|
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
|
||||||
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
||||||
alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
||||||
|
createAlertRuleFromPanel,experimental,@grafana/alerting-squad,false,false,true
|
||||||
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
|
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
|
||||||
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
|
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
|
||||||
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
||||||
|
|||||||
|
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -939,6 +939,19 @@
|
|||||||
"frontend": true
|
"frontend": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "createAlertRuleFromPanel",
|
||||||
|
"resourceVersion": "1763546460188",
|
||||||
|
"creationTimestamp": "2025-10-29T09:52:06Z"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"description": "Enables creating alert rules from a panel using a drawer UI",
|
||||||
|
"stage": "experimental",
|
||||||
|
"codeowner": "@grafana/alerting-squad",
|
||||||
|
"frontend": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "dashboardDisableSchemaValidationV1",
|
"name": "dashboardDisableSchemaValidationV1",
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +105,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { render } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||||
|
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||||
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
|
import { GrafanaGroupUpdatedResponse } from '../api/alertRuleModel';
|
||||||
|
import { ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
|
|
||||||
|
import { AlertRuleDrawerForm, AlertRuleDrawerFormProps } from './AlertRuleDrawerForm';
|
||||||
|
|
||||||
|
setupMswServer();
|
||||||
|
|
||||||
|
// Mock the hooks
|
||||||
|
const mockExecute = jest.fn();
|
||||||
|
jest.mock('../hooks/ruleGroup/useUpsertRuleFromRuleGroup', () => ({
|
||||||
|
useAddRuleToRuleGroup: () => [{ execute: mockExecute }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock notification hooks
|
||||||
|
const mockError = jest.fn();
|
||||||
|
const mockSuccess = jest.fn();
|
||||||
|
jest.mock('app/core/copy/appNotification', () => ({
|
||||||
|
useAppNotification: () => ({
|
||||||
|
error: mockError,
|
||||||
|
success: mockSuccess,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps: AlertRuleDrawerFormProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDrawer = (props: Partial<AlertRuleDrawerFormProps> = {}) => {
|
||||||
|
return render(<AlertRuleDrawerForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AlertRuleDrawerForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
grantUserPermissions([
|
||||||
|
AccessControlAction.AlertingRuleCreate,
|
||||||
|
AccessControlAction.AlertingRuleRead,
|
||||||
|
AccessControlAction.AlertingRuleUpdate,
|
||||||
|
AccessControlAction.AlertingRuleDelete,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should not render when isOpen is false', () => {
|
||||||
|
renderDrawer({ isOpen: false });
|
||||||
|
expect(screen.queryByRole('button', { name: /Create/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "Continue in Alerting" button when callback is provided', () => {
|
||||||
|
renderDrawer({ onContinueInAlerting: jest.fn() });
|
||||||
|
expect(screen.getByRole('button', { name: /Continue in Alerting/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cancel button', () => {
|
||||||
|
it('should call onClose when Cancel is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
renderDrawer({ onClose });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset form when Cancel is clicked with prefill', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
const prefill: Partial<RuleFormValues> = {
|
||||||
|
name: 'Prefilled Rule Name',
|
||||||
|
};
|
||||||
|
const { rerender } = renderDrawer({ onClose, prefill });
|
||||||
|
|
||||||
|
// Verify prefilled value is present
|
||||||
|
const nameInput = screen.getByLabelText(/Name/i);
|
||||||
|
expect(nameInput).toHaveValue('Prefilled Rule Name');
|
||||||
|
|
||||||
|
// Modify the field
|
||||||
|
await user.clear(nameInput);
|
||||||
|
await user.type(nameInput, 'Changed Name');
|
||||||
|
expect(nameInput).toHaveValue('Changed Name');
|
||||||
|
|
||||||
|
// Click cancel - this triggers reset to prefill
|
||||||
|
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Reopen to verify reset happened
|
||||||
|
rerender(<AlertRuleDrawerForm {...defaultProps} onClose={onClose} prefill={prefill} isOpen={true} />);
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Continue in Alerting button', () => {
|
||||||
|
it('should call onContinueInAlerting with current form values', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onContinueInAlerting = jest.fn();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
renderDrawer({ onContinueInAlerting, onClose });
|
||||||
|
|
||||||
|
// Fill in a field
|
||||||
|
const nameInput = screen.getByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'Test Rule');
|
||||||
|
|
||||||
|
// Click Continue in Alerting
|
||||||
|
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onContinueInAlerting).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Test Rule',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize contact points when calling onContinueInAlerting', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onContinueInAlerting = jest.fn();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
// Provide prefill with partial contact point data
|
||||||
|
// We intentionally create an incomplete ContactPoint to test that normalizeContactPoints fills in the missing optional fields with defaults
|
||||||
|
const prefill: Partial<RuleFormValues> = {
|
||||||
|
name: 'Test',
|
||||||
|
contactPoints: {
|
||||||
|
grafana: {
|
||||||
|
selectedContactPoint: 'test-contact',
|
||||||
|
} as ContactPoint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderDrawer({ onContinueInAlerting, onClose, prefill });
|
||||||
|
|
||||||
|
// Click Continue in Alerting
|
||||||
|
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onContinueInAlerting).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactPoints: expect.objectContaining({
|
||||||
|
grafana: expect.objectContaining({
|
||||||
|
selectedContactPoint: 'test-contact',
|
||||||
|
// Verify normalization added default values
|
||||||
|
overrideGrouping: false,
|
||||||
|
groupBy: [],
|
||||||
|
overrideTimings: false,
|
||||||
|
groupWaitValue: '',
|
||||||
|
groupIntervalValue: '',
|
||||||
|
repeatIntervalValue: '',
|
||||||
|
muteTimeIntervals: [],
|
||||||
|
activeTimeIntervals: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close drawer after calling onContinueInAlerting', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onContinueInAlerting = jest.fn();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
renderDrawer({ onContinueInAlerting, onClose });
|
||||||
|
|
||||||
|
// Click Continue in Alerting
|
||||||
|
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onContinueInAlerting).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Prefill behavior', () => {
|
||||||
|
it('should initialize form with prefill values', () => {
|
||||||
|
const prefill: Partial<RuleFormValues> = {
|
||||||
|
name: 'Prefilled Rule',
|
||||||
|
type: RuleFormType.grafana,
|
||||||
|
};
|
||||||
|
renderDrawer({ prefill });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset form when prefill changes', async () => {
|
||||||
|
const prefill1: Partial<RuleFormValues> = {
|
||||||
|
name: 'First Rule',
|
||||||
|
};
|
||||||
|
const { rerender } = render(<AlertRuleDrawerForm {...defaultProps} prefill={prefill1} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('First Rule');
|
||||||
|
|
||||||
|
// Update prefill
|
||||||
|
const prefill2: Partial<RuleFormValues> = {
|
||||||
|
name: 'Second Rule',
|
||||||
|
};
|
||||||
|
rerender(<AlertRuleDrawerForm {...defaultProps} prefill={prefill2} />);
|
||||||
|
|
||||||
|
// Wait for the useEffect to trigger the reset
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('Second Rule');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to defaults when prefill becomes undefined', async () => {
|
||||||
|
const prefill: Partial<RuleFormValues> = {
|
||||||
|
name: 'Prefilled Rule',
|
||||||
|
};
|
||||||
|
const { rerender } = render(<AlertRuleDrawerForm {...defaultProps} prefill={prefill} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule');
|
||||||
|
|
||||||
|
// Clear prefill
|
||||||
|
rerender(<AlertRuleDrawerForm {...defaultProps} prefill={undefined} />);
|
||||||
|
|
||||||
|
// Wait for the useEffect to trigger the reset
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create button and submission', () => {
|
||||||
|
it('should close drawer on successful rule creation', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
const mockResponse: GrafanaGroupUpdatedResponse = {
|
||||||
|
message: 'Rule created successfully',
|
||||||
|
created: ['rule-uid'],
|
||||||
|
};
|
||||||
|
mockExecute.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
renderDrawer({ onClose });
|
||||||
|
|
||||||
|
// Fill in required field
|
||||||
|
const nameInput = screen.getByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'Test Alert Rule');
|
||||||
|
|
||||||
|
// Click Create
|
||||||
|
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||||
|
|
||||||
|
// Wait for the execute function to be called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockExecute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drawer should close on success
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show validation error when form is invalid', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDrawer();
|
||||||
|
|
||||||
|
// Try to submit without filling required fields (name is required)
|
||||||
|
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockError).toHaveBeenCalledWith('There are errors in the form. Please correct them and try again!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { Button, Drawer, Stack, useStyles2 } from '@grafana/ui';
|
||||||
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { getMessageFromError } from 'app/core/utils/errors';
|
||||||
|
import { RuleDefinitionSection } from 'app/features/alerting/unified/components/RuleDefinitionSection';
|
||||||
|
|
||||||
|
import { isCloudGroupUpdatedResponse, isGrafanaGroupUpdatedResponse } from '../api/alertRuleModel';
|
||||||
|
import { useAddRuleToRuleGroup } from '../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||||
|
import { getDefaultFormValues } from '../rule-editor/formDefaults';
|
||||||
|
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
|
import { formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||||
|
import { getRuleGroupLocationFromFormValues } from '../utils/rules';
|
||||||
|
|
||||||
|
import { RuleConditionSection } from './RuleConditionSection';
|
||||||
|
import { RuleNotificationSection } from './RuleNotificationSection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes contact point fields to ensure all properties have defined values.
|
||||||
|
* This is only needed for the "Continue in Alerting" flow, which passes RuleFormValues
|
||||||
|
* directly to the rule editor page. The submit flow doesn't need this because
|
||||||
|
* getNotificationSettingsForDTO (called by formValuesToRulerGrafanaRuleDTO) already
|
||||||
|
* handles partial/missing fields when building the DTO for the backend.
|
||||||
|
*/
|
||||||
|
function normalizeContactPoints(
|
||||||
|
contactPoints: AlertManagerManualRouting | undefined
|
||||||
|
): AlertManagerManualRouting | undefined {
|
||||||
|
if (!contactPoints) {
|
||||||
|
return contactPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: AlertManagerManualRouting = {};
|
||||||
|
|
||||||
|
for (const [alertManager, contactPoint] of Object.entries(contactPoints)) {
|
||||||
|
if (contactPoint.selectedContactPoint) {
|
||||||
|
const defaultContactPoint: ContactPoint = {
|
||||||
|
selectedContactPoint: contactPoint.selectedContactPoint,
|
||||||
|
overrideGrouping: contactPoint.overrideGrouping ?? false,
|
||||||
|
groupBy: contactPoint.groupBy ?? [],
|
||||||
|
overrideTimings: contactPoint.overrideTimings ?? false,
|
||||||
|
groupWaitValue: contactPoint.groupWaitValue ?? '',
|
||||||
|
groupIntervalValue: contactPoint.groupIntervalValue ?? '',
|
||||||
|
repeatIntervalValue: contactPoint.repeatIntervalValue ?? '',
|
||||||
|
muteTimeIntervals: contactPoint.muteTimeIntervals ?? [],
|
||||||
|
activeTimeIntervals: contactPoint.activeTimeIntervals ?? [],
|
||||||
|
};
|
||||||
|
normalized[alertManager] = defaultContactPoint;
|
||||||
|
} else {
|
||||||
|
normalized[alertManager] = contactPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertRuleDrawerFormProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
onContinueInAlerting?: (values: RuleFormValues) => void;
|
||||||
|
prefill?: Partial<RuleFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertRuleDrawerForm({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
onContinueInAlerting,
|
||||||
|
prefill,
|
||||||
|
}: AlertRuleDrawerFormProps) {
|
||||||
|
const baseDefaults = useMemo(() => getDefaultFormValues(RuleFormType.grafana), []);
|
||||||
|
const methods = useForm<RuleFormValues>({
|
||||||
|
defaultValues: prefill ? { ...baseDefaults, ...prefill } : baseDefaults,
|
||||||
|
});
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
|
||||||
|
const notifyApp = useAppNotification();
|
||||||
|
|
||||||
|
// Keep form in sync if prefill changes between openings
|
||||||
|
useEffect(() => {
|
||||||
|
methods.reset(prefill ? { ...baseDefaults, ...prefill } : baseDefaults);
|
||||||
|
}, [prefill, methods, baseDefaults]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (values: RuleFormValues) => {
|
||||||
|
try {
|
||||||
|
// The drawer doesn't expose a group field to keep the UX simple.
|
||||||
|
// We derive the group name from the rule name as a sensible default.
|
||||||
|
// The 'default' fallback should rarely occur since 'name' is a required field.
|
||||||
|
const groupName =
|
||||||
|
values.group && values.group.trim().length > 0 ? values.group : values.name?.trim() || 'default';
|
||||||
|
const effectiveValues: RuleFormValues = { ...values, group: groupName };
|
||||||
|
|
||||||
|
const dto = formValuesToRulerGrafanaRuleDTO(effectiveValues);
|
||||||
|
const groupIdentifier = getRuleGroupLocationFromFormValues(effectiveValues);
|
||||||
|
const result = await addRuleToRuleGroup.execute(groupIdentifier, dto, effectiveValues.evaluateEvery);
|
||||||
|
|
||||||
|
if (isGrafanaGroupUpdatedResponse(result)) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cloud rules error response
|
||||||
|
if (isCloudGroupUpdatedResponse(result)) {
|
||||||
|
notifyApp.error('Failed to create rule', result.error);
|
||||||
|
} else {
|
||||||
|
// This should not happen with the current discriminated union types
|
||||||
|
notifyApp.error('Failed to create rule', 'Please review the form and try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = getMessageFromError(err);
|
||||||
|
notifyApp.error('Failed to create rule', errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInvalid = () => {
|
||||||
|
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={title ?? t('alerting.new-rule-from-panel-button.new-alert-rule', 'New alert rule')}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className={styles.outer}>
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<RuleDefinitionSection type={RuleFormType.grafana} />
|
||||||
|
<div className={styles.divider} aria-hidden="true" />
|
||||||
|
<RuleConditionSection />
|
||||||
|
<div className={styles.divider} aria-hidden="true" />
|
||||||
|
<RuleNotificationSection />
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
methods.reset(prefill ? { ...baseDefaults, ...prefill } : baseDefaults);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('alerting.common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
{onContinueInAlerting && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const currentValues = methods.getValues();
|
||||||
|
onContinueInAlerting({
|
||||||
|
...currentValues,
|
||||||
|
contactPoints: normalizeContactPoints(currentValues.contactPoints),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('alerting.simplified.continue-in-alerting', 'Continue in Alerting')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={methods.handleSubmit((values) => submit(values), onInvalid)}
|
||||||
|
disabled={methods.formState.isSubmitting}
|
||||||
|
icon={methods.formState.isSubmitting ? 'spinner' : undefined}
|
||||||
|
>
|
||||||
|
{t('alerting.simplified.create', 'Create')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
outer: css({
|
||||||
|
paddingLeft: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
divider: css({
|
||||||
|
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
margin: `${theme.spacing(3)} 0`,
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
footer: css({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, ReducerID, SelectableValue, getNextRefId } from '@grafana/data';
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxOption,
|
||||||
|
Icon,
|
||||||
|
InlineField,
|
||||||
|
InlineFieldRow,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||||
|
import { ThresholdSelect } from 'app/features/expressions/components/ThresholdSelect';
|
||||||
|
import { ToLabel } from 'app/features/expressions/components/ToLabel';
|
||||||
|
import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types';
|
||||||
|
import { isRangeEvaluator } from 'app/features/expressions/utils/expressionTypes';
|
||||||
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { RuleFormValues } from '../types/rule-form';
|
||||||
|
|
||||||
|
import { EvaluationGroupFieldRow } from './rule-editor/EvaluationGroupFieldRow';
|
||||||
|
|
||||||
|
const ExpressionDatasourceUID = '__expr__';
|
||||||
|
|
||||||
|
type LocalSimpleCondition = { whenField?: string; evaluator: { params: number[]; type: EvalFunction } };
|
||||||
|
|
||||||
|
// Helper function to create expression queries from simple condition
|
||||||
|
function createExpressionQueries(
|
||||||
|
simpleCondition: LocalSimpleCondition,
|
||||||
|
dataQueries: AlertQuery[]
|
||||||
|
): { reduce: AlertQuery; threshold: AlertQuery; condition: string } {
|
||||||
|
const lastDataQueryRefId = dataQueries[dataQueries.length - 1].refId;
|
||||||
|
|
||||||
|
// Always use the same refIds for expressions to keep them stable
|
||||||
|
const existingExpressions = dataQueries.filter((q) => q.datasourceUid === ExpressionDatasourceUID);
|
||||||
|
const reduceRefId = existingExpressions[0]?.refId || getNextRefId(dataQueries);
|
||||||
|
|
||||||
|
// Create a temporary query for threshold refId calculation
|
||||||
|
const tempQueries = [
|
||||||
|
...dataQueries,
|
||||||
|
{
|
||||||
|
refId: reduceRefId,
|
||||||
|
datasourceUid: ExpressionDatasourceUID,
|
||||||
|
queryType: 'expression',
|
||||||
|
model: { refId: reduceRefId },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const thresholdRefId = existingExpressions[1]?.refId || getNextRefId(tempQueries);
|
||||||
|
|
||||||
|
const reduceExpression: ExpressionQuery = {
|
||||||
|
refId: reduceRefId,
|
||||||
|
type: ExpressionQueryType.reduce,
|
||||||
|
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
|
||||||
|
reducer: simpleCondition.whenField || ReducerID.last,
|
||||||
|
expression: lastDataQueryRefId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const thresholdExpression: ExpressionQuery = {
|
||||||
|
refId: thresholdRefId,
|
||||||
|
type: ExpressionQueryType.threshold,
|
||||||
|
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
evaluator: {
|
||||||
|
params: simpleCondition.evaluator.params,
|
||||||
|
type: simpleCondition.evaluator.type,
|
||||||
|
},
|
||||||
|
operator: { type: 'and' },
|
||||||
|
query: { params: [thresholdRefId] },
|
||||||
|
reducer: { params: [], type: 'last' as const },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expression: reduceRefId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
reduce: {
|
||||||
|
refId: reduceRefId,
|
||||||
|
datasourceUid: ExpressionDatasourceUID,
|
||||||
|
queryType: 'expression',
|
||||||
|
model: reduceExpression,
|
||||||
|
},
|
||||||
|
threshold: {
|
||||||
|
refId: thresholdRefId,
|
||||||
|
datasourceUid: ExpressionDatasourceUID,
|
||||||
|
queryType: 'expression',
|
||||||
|
model: thresholdExpression,
|
||||||
|
},
|
||||||
|
condition: thresholdRefId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleConditionSection() {
|
||||||
|
const base = useStyles2(getStyles);
|
||||||
|
const { watch, setValue } = useFormContext<RuleFormValues>();
|
||||||
|
const evaluateFor = watch('evaluateFor') || '0s';
|
||||||
|
const queries = watch('queries');
|
||||||
|
watch('folder');
|
||||||
|
|
||||||
|
const [simpleCondition, setSimpleCondition] = useState<LocalSimpleCondition>({
|
||||||
|
whenField: ReducerID.last,
|
||||||
|
evaluator: { params: [0], type: EvalFunction.IsAbove },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update expression queries whenever simpleCondition changes
|
||||||
|
useEffect(() => {
|
||||||
|
const dataQueries = queries.filter((q) => q.datasourceUid !== ExpressionDatasourceUID);
|
||||||
|
if (dataQueries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reduce, threshold, condition } = createExpressionQueries(simpleCondition, dataQueries);
|
||||||
|
|
||||||
|
setValue('queries', [...dataQueries, reduce, threshold], { shouldDirty: false, shouldValidate: false });
|
||||||
|
setValue('condition', condition, { shouldDirty: false, shouldValidate: false });
|
||||||
|
}, [simpleCondition, queries, setValue]);
|
||||||
|
|
||||||
|
const reducerOptions: Array<ComboboxOption<string>> = reducerTypes
|
||||||
|
.filter((o) => typeof o.value === 'string')
|
||||||
|
.map((o) => ({ value: o.value ?? '', label: o.label ?? String(o.value) }));
|
||||||
|
|
||||||
|
const onReducerTypeChange = (v: ComboboxOption<string> | null) => {
|
||||||
|
const value = v?.value ?? ReducerID.last;
|
||||||
|
setSimpleCondition((prev) => ({ ...prev, whenField: value }));
|
||||||
|
};
|
||||||
|
const isRange = isRangeEvaluator(simpleCondition.evaluator.type);
|
||||||
|
const thresholdFunction = thresholdFunctions.find((fn) => fn.value === simpleCondition.evaluator?.type);
|
||||||
|
const onEvalFunctionChange = (v: SelectableValue<EvalFunction>) => {
|
||||||
|
setSimpleCondition((prev) => ({
|
||||||
|
...prev,
|
||||||
|
evaluator: { ...prev.evaluator, type: v.value ?? EvalFunction.IsAbove },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const onEvaluateValueChange = (e: React.FormEvent<HTMLInputElement>, index = 0) => {
|
||||||
|
const value = parseFloat(e.currentTarget.value) || 0;
|
||||||
|
setSimpleCondition((prev) => ({
|
||||||
|
...prev,
|
||||||
|
evaluator: {
|
||||||
|
...prev.evaluator,
|
||||||
|
params: index === 0 ? [value, prev.evaluator.params[1]] : [prev.evaluator.params[0], value],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={base.section}>
|
||||||
|
<div className={base.sectionHeaderRow}>
|
||||||
|
<div className={base.sectionHeader}>
|
||||||
|
{`2. `}
|
||||||
|
<Trans i18nKey="alerting.simplified.condition.title">Condition</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Stack direction="column" gap={2}>
|
||||||
|
<InlineFieldRow>
|
||||||
|
{simpleCondition.whenField && (
|
||||||
|
<InlineField label={t('alerting.simple-condition-editor.label-when', 'WHEN')}>
|
||||||
|
<Combobox
|
||||||
|
options={reducerOptions}
|
||||||
|
value={simpleCondition.whenField}
|
||||||
|
onChange={onReducerTypeChange}
|
||||||
|
width={20}
|
||||||
|
aria-label={t('alerting.simple-condition-editor.aria-label-reducer', 'Select reducer function')}
|
||||||
|
/>
|
||||||
|
</InlineField>
|
||||||
|
)}
|
||||||
|
<InlineField
|
||||||
|
label={
|
||||||
|
simpleCondition.whenField
|
||||||
|
? t('alerting.simple-condition-editor.label-of-query', 'OF QUERY')
|
||||||
|
: t('alerting.simple-condition-editor.label-when-query', 'WHEN QUERY')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<ThresholdSelect onChange={onEvalFunctionChange} value={thresholdFunction} />
|
||||||
|
{isRange ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
width={10}
|
||||||
|
key={simpleCondition.evaluator.params[0]}
|
||||||
|
defaultValue={simpleCondition.evaluator.params[0] ?? ''}
|
||||||
|
onBlur={(event) => onEvaluateValueChange(event, 0)}
|
||||||
|
/>
|
||||||
|
<ToLabel />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
width={10}
|
||||||
|
key={simpleCondition.evaluator.params[1]}
|
||||||
|
defaultValue={simpleCondition.evaluator.params[1] ?? ''}
|
||||||
|
onBlur={(event) => onEvaluateValueChange(event, 1)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
width={10}
|
||||||
|
key={simpleCondition.evaluator.params[0]}
|
||||||
|
defaultValue={simpleCondition.evaluator.params[0] ?? ''}
|
||||||
|
onBlur={(event) => onEvaluateValueChange(event, 0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
|
||||||
|
<EvaluationGroupFieldRow enableProvisionedGroups={false} />
|
||||||
|
|
||||||
|
{evaluateFor === '0s' && (
|
||||||
|
<Stack direction="row" gap={0.5} alignItems="center">
|
||||||
|
<Icon name="exclamation-triangle" />
|
||||||
|
<Text variant="bodySmall" color="secondary">
|
||||||
|
<Trans i18nKey="alerting.simplified.evaluation.immediate-warning">
|
||||||
|
Immediate firing might lead to unnecessary alerts being sent for temporary issues
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
section: css({ width: '100%' }),
|
||||||
|
sectionHeaderRow: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
sectionHeader: css({
|
||||||
|
fontWeight: theme.typography.fontWeightRegular,
|
||||||
|
fontSize: theme.typography.h4.fontSize,
|
||||||
|
lineHeight: theme.typography.h4.lineHeight,
|
||||||
|
}),
|
||||||
|
paragraphRow: css({ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: theme.spacing(1) }),
|
||||||
|
inlineField: css({ display: 'inline-flex' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import { Field, Input, Stack, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
|
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||||
|
import { isCloudRecordingRuleByType, isGrafanaManagedRuleByType, isRecordingRuleByType } from '../utils/rules';
|
||||||
|
|
||||||
|
import { FolderSelectorV2 } from './rule-editor/FolderSelectorV2';
|
||||||
|
import { LabelsEditorModal } from './rule-editor/labels/LabelsEditorModal';
|
||||||
|
import { LabelsFieldInFormV2 } from './rule-editor/labels/LabelsFieldInFormV2';
|
||||||
|
|
||||||
|
export function RuleDefinitionSection({ type }: { type: RuleFormType }) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
} = useFormContext<RuleFormValues>();
|
||||||
|
const [showLabelsEditor, setShowLabelsEditor] = useState(false);
|
||||||
|
|
||||||
|
const isRecording = isRecordingRuleByType(type);
|
||||||
|
const isCloudRecordingRule = isCloudRecordingRuleByType(type);
|
||||||
|
const namePlaceholder = isRecording ? 'recording rule' : 'alert rule';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeaderRow}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
{`1. `}
|
||||||
|
<Trans i18nKey="alerting.simplified.rule-definition">Rule Definition</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Stack direction="column" gap={2}>
|
||||||
|
<Field
|
||||||
|
noMargin
|
||||||
|
label={
|
||||||
|
<span className={styles.nameLabel}>
|
||||||
|
<Trans i18nKey="alerting.alert-rule-name-and-metric.label-name">Name</Trans>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
error={errors?.name?.message}
|
||||||
|
invalid={!!errors.name?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
data-testid={selectors.components.AlertRules.ruleNameField}
|
||||||
|
id="name"
|
||||||
|
width={38}
|
||||||
|
{...register('name', {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: t('alerting.alert-rule-name-and-metric.message.must-enter-a-name', 'Must enter a name'),
|
||||||
|
},
|
||||||
|
pattern: isCloudRecordingRule
|
||||||
|
? {
|
||||||
|
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
|
||||||
|
message: t(
|
||||||
|
'alerting.alert-rule-name-and-metric.recording-rule-pattern',
|
||||||
|
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})}
|
||||||
|
aria-label={t('alerting.alert-rule-name-and-metric.aria-label-name', 'name')}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.alert-rule-name-and-metric.placeholder-name',
|
||||||
|
'Give your {{namePlaceholder}} a name',
|
||||||
|
{ namePlaceholder }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{isGrafanaManagedRuleByType(type) && (
|
||||||
|
<>
|
||||||
|
<FolderSelectorV2 />
|
||||||
|
<LabelsFieldInFormV2 onEditClick={() => setShowLabelsEditor(true)} />
|
||||||
|
<LabelsEditorModal
|
||||||
|
isOpen={showLabelsEditor}
|
||||||
|
onClose={(labelsToUpdate) => {
|
||||||
|
if (labelsToUpdate) {
|
||||||
|
const filtered = labelsToUpdate.filter(
|
||||||
|
(l) => (l?.key ?? '').length > 0 || (l?.value ?? '').length > 0
|
||||||
|
);
|
||||||
|
setValue('labels', filtered, { shouldDirty: true, shouldValidate: true });
|
||||||
|
}
|
||||||
|
setShowLabelsEditor(false);
|
||||||
|
}}
|
||||||
|
dataSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||||
|
initialLabels={getValues('labels')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
section: css({ width: '100%' }),
|
||||||
|
sectionHeaderRow: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
sectionHeader: css({
|
||||||
|
fontWeight: theme.typography.fontWeightRegular,
|
||||||
|
fontSize: theme.typography.h4.fontSize,
|
||||||
|
lineHeight: theme.typography.h4.lineHeight,
|
||||||
|
}),
|
||||||
|
nameLabel: css({
|
||||||
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { notificationsAPIv0alpha1 } from '@grafana/alerting/unstable';
|
||||||
|
import type { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Combobox,
|
||||||
|
ComboboxOption,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
RadioButtonGroup,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextArea,
|
||||||
|
TextLink,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
|
|
||||||
|
import { RuleFormValues } from '../types/rule-form';
|
||||||
|
import { Annotation } from '../utils/constants';
|
||||||
|
|
||||||
|
import { NeedHelpInfoForNotificationPolicy } from './rule-editor/NotificationsStep';
|
||||||
|
|
||||||
|
// Centralized form path for selected contact point
|
||||||
|
const CONTACT_POINT_PATH = 'contactPoints.grafana.selectedContactPoint' as const;
|
||||||
|
|
||||||
|
export function RuleNotificationSection() {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const notifyApp = useAppNotification();
|
||||||
|
|
||||||
|
const { watch, setValue } = useFormContext<RuleFormValues>();
|
||||||
|
const manualRouting = watch('manualRouting');
|
||||||
|
const useNotificationPolicy = !manualRouting;
|
||||||
|
const selectedContactPoint = watch(CONTACT_POINT_PATH);
|
||||||
|
const annotations = watch('annotations');
|
||||||
|
|
||||||
|
// Fetch contact points from Alerting API v0alpha1
|
||||||
|
const { currentData, status, refetch } = notificationsAPIv0alpha1.endpoints.listReceiver.useQuery({});
|
||||||
|
const options = useMemo<Array<ComboboxOption<string>>>(
|
||||||
|
() =>
|
||||||
|
(currentData?.items ?? []).map((item) => ({
|
||||||
|
value: item?.spec?.title ?? '',
|
||||||
|
label: item?.spec?.title ?? '',
|
||||||
|
})),
|
||||||
|
[currentData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper functions to get and set annotation values
|
||||||
|
const getAnnotationValue = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
return annotations.find((a) => a.key === key)?.value ?? '';
|
||||||
|
},
|
||||||
|
[annotations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAnnotationValue = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
const updatedAnnotations = [...annotations];
|
||||||
|
const index = updatedAnnotations.findIndex((a) => a.key === key);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
updatedAnnotations[index] = { key, value };
|
||||||
|
} else {
|
||||||
|
updatedAnnotations.push({ key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('annotations', updatedAnnotations, { shouldDirty: true, shouldValidate: true });
|
||||||
|
},
|
||||||
|
[annotations, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryValue = getAnnotationValue(Annotation.summary);
|
||||||
|
const descriptionValue = getAnnotationValue(Annotation.description);
|
||||||
|
const runbookUrlValue = getAnnotationValue(Annotation.runbookURL);
|
||||||
|
|
||||||
|
const recipientLabelId = 'recipient-label';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeaderRow}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
{`3. `}
|
||||||
|
<Trans i18nKey="alerting.simplified.notification.title">Notification</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Stack direction="column" gap={2}>
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<Stack direction="row" alignItems="end" justifyContent="space-between" gap={1}>
|
||||||
|
<Label htmlFor={recipientLabelId}>
|
||||||
|
<span id={recipientLabelId}>
|
||||||
|
{t('alerting.simplified.notification.recipient.label', 'Recipient')}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<div className={styles.manualRoutingInline}>
|
||||||
|
<RadioButtonGroup
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'alerting.manual-and-automatic-routing.routing-options.label.contact-point',
|
||||||
|
'Contact point'
|
||||||
|
),
|
||||||
|
value: 'contact',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'alerting.manual-and-automatic-routing.routing-options.label.notification-policy',
|
||||||
|
'Notification policy'
|
||||||
|
),
|
||||||
|
value: 'policy',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={manualRouting ? 'contact' : 'policy'}
|
||||||
|
onChange={(val: 'contact' | 'policy') => {
|
||||||
|
const next = val === 'contact';
|
||||||
|
setValue('manualRouting', next, { shouldDirty: true, shouldValidate: true });
|
||||||
|
setValue('editorSettings.simplifiedNotificationEditor', next, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={t('alerting.simplified.notification.manual-routing.aria', 'Toggle manual routing')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
<Text variant="bodySmall" color="secondary">
|
||||||
|
{useNotificationPolicy ? (
|
||||||
|
<Trans i18nKey="alerting.simplified.notification.policy-selected">
|
||||||
|
Notifications for firing alerts are routed to contact points based on matching labels and the
|
||||||
|
notification policy tree.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="alerting.simplified.notification.contact-point-selected">
|
||||||
|
Notifications for firing alerts are routed to a selected contact point.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{useNotificationPolicy ? (
|
||||||
|
<div className={styles.contentTopSpacer}>
|
||||||
|
<NeedHelpInfoForNotificationPolicy />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.contentTopSpacer}>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<Combobox<ComboboxOption<string>['value']>
|
||||||
|
options={options}
|
||||||
|
value={
|
||||||
|
selectedContactPoint ? (options.find((o) => o.value === selectedContactPoint) ?? null) : null
|
||||||
|
}
|
||||||
|
onChange={(opt) =>
|
||||||
|
setValue(CONTACT_POINT_PATH, opt?.value ?? '', {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
width={30}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.simplified.notification.select-contact-point',
|
||||||
|
'Select a contact point...'
|
||||||
|
)}
|
||||||
|
isClearable
|
||||||
|
data-testid="contact-point"
|
||||||
|
loading={status === 'pending'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="sync"
|
||||||
|
variant="secondary"
|
||||||
|
fill="text"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('alerting.common.refresh', 'Refresh')}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
notifyApp.error(
|
||||||
|
t('alerting.simplified.notification.refresh-error', 'Failed to refresh contact points')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextLink
|
||||||
|
href={'/alerting/notifications'}
|
||||||
|
aria-label={t(
|
||||||
|
'alerting.link-to-contact-points.aria-label-view-or-create-contact-points',
|
||||||
|
'View or create contact points'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.link-to-contact-points.view-or-create-contact-points">
|
||||||
|
View or create contact points
|
||||||
|
</Trans>
|
||||||
|
</TextLink>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Field label={t('alerting.simplified.notification.summary.label', 'Summary (optional)')} noMargin>
|
||||||
|
<TextArea
|
||||||
|
id="summary-text-area"
|
||||||
|
value={summaryValue}
|
||||||
|
onChange={(e) => updateAnnotationValue(Annotation.summary, e.currentTarget.value)}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.simplified.notification.summary.placeholder',
|
||||||
|
'Enter a summary of what happened and why…'
|
||||||
|
)}
|
||||||
|
aria-label={t('alerting.simplified.notification.summary.aria-label', 'Summary')}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t('alerting.simplified.notification.description.label', 'Description (optional)')} noMargin>
|
||||||
|
<TextArea
|
||||||
|
id="description-text-area"
|
||||||
|
value={descriptionValue}
|
||||||
|
onChange={(e) => updateAnnotationValue(Annotation.description, e.currentTarget.value)}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.simplified.notification.description.placeholder',
|
||||||
|
'Enter a description of what the alert rule does…'
|
||||||
|
)}
|
||||||
|
aria-label={t('alerting.simplified.notification.description.aria-label', 'Description')}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t('alerting.simplified.notification.runbook-url.label', 'Runbook URL (optional)')} noMargin>
|
||||||
|
<Input
|
||||||
|
id="runbook-url-input"
|
||||||
|
type="url"
|
||||||
|
value={runbookUrlValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
updateAnnotationValue(Annotation.runbookURL, value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = e.currentTarget.value.trim();
|
||||||
|
// Validate URL on blur
|
||||||
|
if (value && value !== '') {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
// Reject dangerous URL schemes
|
||||||
|
if (url.protocol === 'javascript:' || url.protocol === 'data:' || url.protocol === 'vbscript:') {
|
||||||
|
notifyApp.error(
|
||||||
|
t(
|
||||||
|
'alerting.simplified.notification.runbook-url.invalid-protocol',
|
||||||
|
'Invalid URL protocol. Please use http or https.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notifyApp.warning(
|
||||||
|
t(
|
||||||
|
'alerting.simplified.notification.runbook-url.invalid-format',
|
||||||
|
'Invalid URL format. Please enter a valid URL.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.simplified.notification.runbook-url.placeholder',
|
||||||
|
'Enter the webpage where you keep your runbook for the alert…'
|
||||||
|
)}
|
||||||
|
aria-label={t('alerting.simplified.notification.runbook-url.aria-label', 'Runbook URL')}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
section: css({ width: '100%' }),
|
||||||
|
sectionHeaderRow: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
sectionHeader: css({
|
||||||
|
fontWeight: theme.typography.fontWeightRegular,
|
||||||
|
fontSize: theme.typography.h4.fontSize,
|
||||||
|
lineHeight: theme.typography.h4.lineHeight,
|
||||||
|
}),
|
||||||
|
contentTopSpacer: css({ marginTop: theme.spacing(0.5) }),
|
||||||
|
manualRoutingInline: css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ export const CreateNewFolder = ({ onCreate }: { onCreate: (folder: Folder) => vo
|
|||||||
onClick={() => setIsCreatingFolder(true)}
|
onClick={() => setIsCreatingFolder(true)}
|
||||||
type="button"
|
type="button"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
fill="outline"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||||
>
|
>
|
||||||
<Trans i18nKey="alerting.create-new-folder.new-folder">New folder</Trans>
|
<Trans i18nKey="alerting.create-new-folder.new-folder">New folder</Trans>
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ jest.mock('react-use', () => ({
|
|||||||
useAsync: () => ({ loading: false, value: {} }),
|
useAsync: () => ({ loading: false, value: {} }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
|
featureToggles: {
|
||||||
|
createAlertRuleFromPanel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../components/AlertRuleDrawerForm', () => ({
|
||||||
|
AlertRuleDrawerForm: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Analytics', () => {
|
describe('Analytics', () => {
|
||||||
it('Sends log info when creating an alert rule from a panel', async () => {
|
it('Sends log info when creating an alert rule from a panel', async () => {
|
||||||
const panel = new PanelModel({
|
const panel = new PanelModel({
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom-v5-compat';
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { urlUtil } from '@grafana/data';
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { Alert, Button, LinkButton } from '@grafana/ui';
|
import { Alert, Button } from '@grafana/ui';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||||
import { useSelector } from 'app/types/store';
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import { LogMessages, logInfo } from '../../Analytics';
|
import { LogMessages, logInfo } from '../../Analytics';
|
||||||
|
import { AlertRuleDrawerForm } from '../../components/AlertRuleDrawerForm';
|
||||||
|
import { createPanelAlertRuleNavigation } from '../../utils/navigation';
|
||||||
import { panelToRuleFormValues } from '../../utils/rule-form';
|
import { panelToRuleFormValues } from '../../utils/rule-form';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,6 +31,7 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) =
|
|||||||
// Templating variables are required to update formValues on each variable's change. It's used implicitly by the templating engine
|
// Templating variables are required to update formValues on each variable's change. It's used implicitly by the templating engine
|
||||||
[panel, dashboard, templating]
|
[panel, dashboard, templating]
|
||||||
);
|
);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,20 +57,30 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ruleFormUrl = urlUtil.renderUrl('alerting/new', {
|
const { onContinueInAlertingFromDrawer } = createPanelAlertRuleNavigation(
|
||||||
defaults: JSON.stringify(formValues),
|
() => panelToRuleFormValues(panel, dashboard),
|
||||||
returnTo: location.pathname + location.search,
|
location
|
||||||
});
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkButton
|
<>
|
||||||
icon="bell"
|
<Button
|
||||||
onClick={() => logInfo(LogMessages.alertRuleFromPanel)}
|
icon="bell"
|
||||||
href={ruleFormUrl}
|
className={className}
|
||||||
className={className}
|
data-testid="create-alert-rule-button-drawer"
|
||||||
data-testid="create-alert-rule-button"
|
onClick={() => {
|
||||||
>
|
logInfo(LogMessages.alertRuleFromPanel);
|
||||||
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
|
setIsOpen(true);
|
||||||
</LinkButton>
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
|
||||||
|
</Button>
|
||||||
|
<AlertRuleDrawerForm
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
onContinueInAlerting={onContinueInAlertingFromDrawer}
|
||||||
|
prefill={formValues ?? undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useId, useMemo, useState } from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import { Box, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||||
|
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { useFetchGroupsForFolder } from '../../hooks/useFetchGroupsForFolder';
|
||||||
|
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||||
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { isProvisionedRuleGroup } from '../../utils/rules';
|
||||||
|
import { ProvisioningBadge } from '../Provisioning';
|
||||||
|
|
||||||
|
import { EvaluationGroupCreationModal } from './GrafanaEvaluationBehavior';
|
||||||
|
|
||||||
|
export type GroupOption = SelectableValue<string> & { isProvisioned?: boolean };
|
||||||
|
|
||||||
|
export function EvaluationGroupFieldRow({ enableProvisionedGroups }: { enableProvisionedGroups: boolean }) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const groupInputId = useId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
formState: { errors },
|
||||||
|
control,
|
||||||
|
} = useFormContext<RuleFormValues>();
|
||||||
|
|
||||||
|
const [group, folder] = watch(['group', 'folder']);
|
||||||
|
const { currentData: rulerNamespace, isLoading: loadingGroups } = useFetchGroupsForFolder(folder?.uid ?? '');
|
||||||
|
|
||||||
|
const collator = useMemo(() => new Intl.Collator(), []);
|
||||||
|
const groupOptions = useMemo<GroupOption[]>(() => {
|
||||||
|
if (!rulerNamespace) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const folderGroups = Object.values(rulerNamespace).flat();
|
||||||
|
return folderGroups
|
||||||
|
.map<GroupOption>((g: RulerRuleGroupDTO) => {
|
||||||
|
const provisioned = isProvisionedRuleGroup(g);
|
||||||
|
return {
|
||||||
|
label: g.name,
|
||||||
|
value: g.name,
|
||||||
|
description: g.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||||
|
isDisabled: !enableProvisionedGroups ? provisioned : false,
|
||||||
|
isProvisioned: provisioned,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => collator.compare(a.label ?? '', b.label ?? ''));
|
||||||
|
}, [collator, enableProvisionedGroups, rulerNamespace]);
|
||||||
|
|
||||||
|
const defaultGroupValue = group ? { value: group, label: group } : undefined;
|
||||||
|
|
||||||
|
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
|
||||||
|
const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true);
|
||||||
|
|
||||||
|
const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => {
|
||||||
|
setValue('group', groupName);
|
||||||
|
setValue('evaluateEvery', evaluationInterval);
|
||||||
|
setIsCreatingEvaluationGroup(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = !folder?.uid
|
||||||
|
? t(
|
||||||
|
'alerting.rule-form.evaluation.select-folder-before',
|
||||||
|
'Select a folder before setting evaluation group and interval'
|
||||||
|
)
|
||||||
|
: t('alerting.rule-form.evaluation.evaluation-group-and-interval', 'Evaluation group and interval');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack alignItems="end">
|
||||||
|
<div className={styles.formContainer}>
|
||||||
|
<Field
|
||||||
|
noMargin
|
||||||
|
label={label}
|
||||||
|
data-testid="group-picker"
|
||||||
|
className={styles.formInput}
|
||||||
|
error={errors.group?.message}
|
||||||
|
invalid={!!errors.group?.message}
|
||||||
|
htmlFor="group"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
render={({ field: { ref, ...field }, fieldState }) => (
|
||||||
|
<Select
|
||||||
|
disabled={!folder?.uid || loadingGroups}
|
||||||
|
inputId={groupInputId}
|
||||||
|
{...field}
|
||||||
|
onChange={(group) => {
|
||||||
|
field.onChange(group.label ?? '');
|
||||||
|
}}
|
||||||
|
isLoading={loadingGroups}
|
||||||
|
invalid={Boolean(folder?.uid) && !group && Boolean(fieldState.error)}
|
||||||
|
cacheOptions
|
||||||
|
loadingMessage={t(
|
||||||
|
'alerting.grafana-evaluation-behavior-step.loadingMessage-loading-groups',
|
||||||
|
'Loading groups...'
|
||||||
|
)}
|
||||||
|
defaultValue={defaultGroupValue}
|
||||||
|
options={groupOptions}
|
||||||
|
getOptionLabel={(option: GroupOption) => (
|
||||||
|
<div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{option.isProvisioned && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ProvisioningBadge />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
'alerting.grafana-evaluation-behavior-step.placeholder-select-an-evaluation-group',
|
||||||
|
'Select an evaluation group...'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
name="group"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: t(
|
||||||
|
'alerting.grafana-evaluation-behavior-step.message.must-enter-a-group-name',
|
||||||
|
'Must enter a group name'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Box gap={1} display={'flex'} alignItems={'center'}>
|
||||||
|
<Text color="secondary">
|
||||||
|
<Trans i18nKey="alerting.grafana-evaluation-behavior-step.or">or</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
onClick={onOpenEvaluationGroupCreationModal}
|
||||||
|
type="button"
|
||||||
|
icon="plus"
|
||||||
|
fill="outline"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!folder?.uid}
|
||||||
|
data-testid={'new-evaluation-group-button'}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.rule-form.evaluation.new-group">New evaluation group</Trans>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{isCreatingEvaluationGroup && (
|
||||||
|
<EvaluationGroupCreationModal
|
||||||
|
onCreate={handleEvalGroupCreation}
|
||||||
|
onClose={() => setIsCreatingEvaluationGroup(false)}
|
||||||
|
groupfoldersForGrafana={rulerNamespace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{getValues('group') && getValues('evaluateEvery') && (
|
||||||
|
<div className={styles.evaluationContainer}>
|
||||||
|
<Stack direction="column" gap={0}>
|
||||||
|
<div className={styles.marginTop}>
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="alerting.rule-form.evaluation.group-text"
|
||||||
|
values={{ evaluateEvery: getValues('evaluateEvery') }}
|
||||||
|
>
|
||||||
|
All rules in the selected group are evaluated every {{ evaluateEvery: getValues('evaluateEvery') }}.
|
||||||
|
</Trans>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
formContainer: css({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.breakpoints.values.sm,
|
||||||
|
}),
|
||||||
|
formInput: css({
|
||||||
|
flexGrow: 1,
|
||||||
|
}),
|
||||||
|
evaluationContainer: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
maxWidth: `${theme.breakpoints.values.sm}px`,
|
||||||
|
fontSize: theme.typography.size.sm,
|
||||||
|
}),
|
||||||
|
marginTop: css({
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { Button, Stack } from '@grafana/ui';
|
|||||||
|
|
||||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
|
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
|
||||||
|
|
||||||
const MIN_INTERVAl = config.unifiedAlerting.minInterval ?? '10s';
|
const MIN_INTERVAl = config.unifiedAlerting?.minInterval ?? '10s';
|
||||||
export const getEvaluationGroupOptions = (minInterval = MIN_INTERVAl) => {
|
export const getEvaluationGroupOptions = (minInterval = MIN_INTERVAl) => {
|
||||||
const MIN_OPTIONS_TO_SHOW = 8;
|
const MIN_OPTIONS_TO_SHOW = 8;
|
||||||
const DEFAULT_INTERVAL_OPTIONS: number[] = [
|
const DEFAULT_INTERVAL_OPTIONS: number[] = [
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import { Field, Icon, Label, Stack, Tooltip } from '@grafana/ui';
|
||||||
|
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
|
||||||
|
|
||||||
|
import { Folder, RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { CreateNewFolder } from '../create-folder/CreateNewFolder';
|
||||||
|
|
||||||
|
export function FolderSelectorV2() {
|
||||||
|
const {
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useFormContext<RuleFormValues>();
|
||||||
|
|
||||||
|
const resetGroup = useCallback(() => {
|
||||||
|
setValue('group', '');
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
|
const folder = watch('folder');
|
||||||
|
|
||||||
|
const handleFolderCreation = (folder: Folder) => {
|
||||||
|
resetGroup();
|
||||||
|
setValue('folder', folder);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack alignItems="center">
|
||||||
|
{
|
||||||
|
<Field
|
||||||
|
noMargin
|
||||||
|
label={
|
||||||
|
<Label
|
||||||
|
htmlFor="folder"
|
||||||
|
description={t(
|
||||||
|
'alerting.folder-selector.description-select-folder',
|
||||||
|
'Select a folder to store your rule in.'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
|
<Trans i18nKey="alerting.rule-form.folder.label">Folder</Trans>
|
||||||
|
<Tooltip
|
||||||
|
content={t(
|
||||||
|
'alerting.rule-form.folders.help-info',
|
||||||
|
'Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders.'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name="info-circle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
error={errors.folder?.message}
|
||||||
|
data-testid="folder-picker"
|
||||||
|
>
|
||||||
|
<Stack direction="column" alignItems="flex-start" gap={1}>
|
||||||
|
<Controller
|
||||||
|
render={({ field: { ref, ...field } }) => (
|
||||||
|
<div style={{ width: 420 }}>
|
||||||
|
<NestedFolderPicker
|
||||||
|
permission="view"
|
||||||
|
showRootFolder={false}
|
||||||
|
invalid={!!errors.folder?.message}
|
||||||
|
{...field}
|
||||||
|
value={folder?.uid}
|
||||||
|
onChange={(uid, title) => {
|
||||||
|
if (uid && title) {
|
||||||
|
setValue('folder', { title, uid });
|
||||||
|
} else {
|
||||||
|
setValue('folder', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetGroup();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
name="folder"
|
||||||
|
rules={{
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: t('alerting.folder-selector.message.select-a-folder', 'Select a folder'),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateNewFolder onCreate={handleFolderCreation} />
|
||||||
|
</Stack>
|
||||||
|
</Field>
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -439,7 +439,7 @@ export function GrafanaEvaluationBehaviorStep({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvaluationGroupCreationModal({
|
export function EvaluationGroupCreationModal({
|
||||||
onClose,
|
onClose,
|
||||||
onCreate,
|
onCreate,
|
||||||
groupfoldersForGrafana,
|
groupfoldersForGrafana,
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ function AutomaticRooting({ alertUid }: AutomaticRootingProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auxiliar components to build the texts and descriptions in the NotificationsStep
|
// Auxiliar components to build the texts and descriptions in the NotificationsStep
|
||||||
function NeedHelpInfoForNotificationPolicy() {
|
export function NeedHelpInfoForNotificationPolicy() {
|
||||||
return (
|
return (
|
||||||
<NeedHelpInfo
|
<NeedHelpInfo
|
||||||
contentText={
|
contentText={
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,7 +115,9 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -132,7 +134,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -263,7 +265,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -281,7 +283,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -298,7 +302,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -432,7 +436,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -450,7 +454,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -467,7 +473,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -604,7 +610,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -622,7 +628,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -639,7 +647,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -773,7 +781,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -791,7 +799,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -808,7 +818,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function LinkToContactPoints() {
|
export function LinkToContactPoints() {
|
||||||
const hrefToContactPoints = '/alerting/notifications';
|
const hrefToContactPoints = '/alerting/notifications';
|
||||||
return (
|
return (
|
||||||
<TextLink
|
<TextLink
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ function mapLabelsToOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LabelsInRuleProps {
|
export interface LabelsInRuleProps {
|
||||||
labels: Array<{ key: string; value: string }>;
|
labels: Array<{ key: string; value: string }> | undefined | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LabelsInRule = ({ labels }: LabelsInRuleProps) => {
|
export const LabelsInRule = ({ labels }: LabelsInRuleProps) => {
|
||||||
const labelsObj: Record<string, string> = labels.reduce((acc: Record<string, string>, label) => {
|
const safeLabels = Array.isArray(labels) ? labels : [];
|
||||||
|
const labelsObj: Record<string, string> = safeLabels.reduce((acc: Record<string, string>, label) => {
|
||||||
if (label.key) {
|
if (label.key) {
|
||||||
acc[label.key] = label.value;
|
acc[label.key] = label.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import { Button, Field, Stack, Text } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { AIImproveLabelsButtonComponent } from '../../../enterprise-components/AI/AIGenImproveLabelsButton/addAIImproveLabelsButton';
|
||||||
|
import { RuleFormValues } from '../../../types/rule-form';
|
||||||
|
import { isGrafanaManagedRuleByType, isRecordingRuleByType } from '../../../utils/rules';
|
||||||
|
|
||||||
|
import { LabelsInRule } from './LabelsField';
|
||||||
|
|
||||||
|
interface LabelsFieldInFormProps {
|
||||||
|
onEditClick: () => void;
|
||||||
|
}
|
||||||
|
export function LabelsFieldInFormV2({ onEditClick }: LabelsFieldInFormProps) {
|
||||||
|
const { control, watch } = useFormContext<RuleFormValues>();
|
||||||
|
|
||||||
|
// Subscribe to label changes so UI updates when modal saves
|
||||||
|
const labels = useWatch({ control, name: 'labels' }) ?? [];
|
||||||
|
const type = watch('type');
|
||||||
|
|
||||||
|
const isRecordingRule = type ? isRecordingRuleByType(type) : false;
|
||||||
|
const isGrafanaManaged = type ? isGrafanaManagedRuleByType(type) : false;
|
||||||
|
|
||||||
|
const text = isRecordingRule
|
||||||
|
? t('alerting.alertform.labels.recording', 'Add labels to your rule.')
|
||||||
|
: t(
|
||||||
|
'alerting.alertform.labels.alerting',
|
||||||
|
'Add labels to your rule for searching, silencing, or routing to a notification policy.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasLabels = Array.isArray(labels) && labels.length > 0 && labels.some((label) => label?.key || label?.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
noMargin
|
||||||
|
label={
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
|
<Text variant="bodySmall">
|
||||||
|
<Trans i18nKey="alerting.labels-field-in-form.labels">Labels</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySmall" color="secondary">
|
||||||
|
{t('alerting.common.optional', '(optional)')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack direction={'column'} gap={2}>
|
||||||
|
<Stack direction={'column'} gap={1}>
|
||||||
|
<Stack direction={'row'} gap={1}>
|
||||||
|
<Text variant="bodySmall" color="secondary">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{isGrafanaManaged && <AIImproveLabelsButtonComponent />}
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
{hasLabels ? (
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<LabelsInRule labels={labels} />
|
||||||
|
<Button variant="secondary" type="button" onClick={onEditClick} size="sm">
|
||||||
|
<Trans i18nKey="alerting.labels-field-in-form.edit-labels">Edit labels</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack direction="column" gap={0.5} alignItems="start">
|
||||||
|
<Text color="secondary">
|
||||||
|
<Trans i18nKey="alerting.labels-field-in-form.no-labels-selected">No labels selected</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
icon="plus"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onEditClick}
|
||||||
|
size="sm"
|
||||||
|
data-testid="add-labels-button"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.labels-field-in-form.add-labels">Add labels</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,7 +117,9 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -134,7 +136,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -251,10 +251,12 @@ export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormVa
|
|||||||
parsedRule = alertingAlertRuleFormSchema.parse(rule);
|
parsedRule = alertingAlertRuleFormSchema.parse(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
return revealHiddenQueries({
|
return setQueryEditorSettings(
|
||||||
...getDefaultFormValues(rule.type),
|
revealHiddenQueries({
|
||||||
...parsedRule,
|
...getDefaultFormValues(rule.type),
|
||||||
});
|
...parsedRule,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
|
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
|
||||||
|
|||||||
@@ -27,8 +27,34 @@ export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
|
|||||||
// data queries only
|
// data queries only
|
||||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||||
|
|
||||||
// expression queries only
|
// expression queries only - but filter out invalid ones that don't have a type field
|
||||||
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
|
const expressionQueries = values.queries.filter((query): query is AlertQuery<ExpressionQuery> => {
|
||||||
|
if (!isExpressionQueryInAlert(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if the expression has a valid type field
|
||||||
|
// React Hook Form might strip the type field, so we need to check it exists
|
||||||
|
return 'type' in query.model && query.model.type !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have data queries but no VALID expressions (e.g., coming from dashboard panel with malformed expressions),
|
||||||
|
// remove the invalid expressions and set condition to empty so simplified mode can regenerate them
|
||||||
|
const hasDataQueries = dataQueries.length > 0;
|
||||||
|
const hasValidExpressions = expressionQueries.length > 0;
|
||||||
|
const totalExpressions = values.queries.filter((query) => isExpressionQueryInAlert(query)).length;
|
||||||
|
const hasInvalidExpressions = totalExpressions > expressionQueries.length;
|
||||||
|
|
||||||
|
if (hasDataQueries && (!hasValidExpressions || hasInvalidExpressions)) {
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
queries: dataQueries, // Only keep data queries, remove invalid expressions
|
||||||
|
condition: '', // Clear condition so simplified editor can set it
|
||||||
|
editorSettings: {
|
||||||
|
simplifiedQueryEditor: true,
|
||||||
|
simplifiedNotificationEditor: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
|
const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { urlUtil } from '@grafana/data';
|
||||||
|
import { locationService, logInfo } from '@grafana/runtime';
|
||||||
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
|
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
|
import { LogMessages } from '../Analytics';
|
||||||
import { createReturnTo } from '../hooks/useReturnTo';
|
import { createReturnTo } from '../hooks/useReturnTo';
|
||||||
|
import { RuleFormValues } from '../types/rule-form';
|
||||||
|
|
||||||
import { stringifyIdentifier } from './rule-id';
|
import { stringifyIdentifier } from './rule-id';
|
||||||
import { createRelativeUrl } from './url';
|
import { createRelativeUrl } from './url';
|
||||||
@@ -99,3 +103,35 @@ export const notificationPolicies = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPanelAlertRuleNavigation = (
|
||||||
|
getFormValues: () => Promise<Partial<RuleFormValues> | undefined>,
|
||||||
|
location: { pathname: string; search: string }
|
||||||
|
) => {
|
||||||
|
const navigateToAlerting = async (currentValues?: RuleFormValues) => {
|
||||||
|
logInfo(LogMessages.alertRuleFromPanel);
|
||||||
|
|
||||||
|
const updateToDateFormValues = currentValues ?? (await getFormValues());
|
||||||
|
|
||||||
|
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
|
||||||
|
defaults: JSON.stringify(updateToDateFormValues),
|
||||||
|
returnTo: location.pathname + location.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
locationService.push(ruleFormUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContinueInAlertingFromDrawer = (values: RuleFormValues) => {
|
||||||
|
void navigateToAlerting(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onButtonClick = () => {
|
||||||
|
void navigateToAlerting(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToAlerting,
|
||||||
|
onContinueInAlertingFromDrawer,
|
||||||
|
onButtonClick,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PromQuery } from '@grafana/prometheus';
|
import { PromQuery } from '@grafana/prometheus';
|
||||||
import { ExpressionDatasourceUID, ExpressionQueryType } from 'app/features/expressions/types';
|
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
import {
|
import {
|
||||||
AlertDataQuery,
|
AlertDataQuery,
|
||||||
@@ -52,6 +52,35 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
|
|||||||
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
|
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets notification_settings.receiver only when manualRouting is true', () => {
|
||||||
|
const base: RuleFormValues = {
|
||||||
|
...getDefaultFormValues(),
|
||||||
|
type: RuleFormType.grafana,
|
||||||
|
condition: 'A',
|
||||||
|
contactPoints: {
|
||||||
|
grafana: {
|
||||||
|
selectedContactPoint: 'team-receiver',
|
||||||
|
muteTimeIntervals: [],
|
||||||
|
activeTimeIntervals: [],
|
||||||
|
overrideGrouping: false,
|
||||||
|
overrideTimings: false,
|
||||||
|
groupBy: [],
|
||||||
|
groupWaitValue: '',
|
||||||
|
groupIntervalValue: '',
|
||||||
|
repeatIntervalValue: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// manualRouting false → no notification_settings
|
||||||
|
const dtoNoManual = formValuesToRulerGrafanaRuleDTO({ ...base, manualRouting: false });
|
||||||
|
expect(dtoNoManual.grafana_alert.notification_settings).toBeUndefined();
|
||||||
|
|
||||||
|
// manualRouting true → notification_settings.receiver present
|
||||||
|
const dtoManual = formValuesToRulerGrafanaRuleDTO({ ...base, manualRouting: true });
|
||||||
|
expect(dtoManual.grafana_alert.notification_settings?.receiver).toBe('team-receiver');
|
||||||
|
});
|
||||||
|
|
||||||
it('should not save both instant and range type queries', () => {
|
it('should not save both instant and range type queries', () => {
|
||||||
const defaultValues = getDefaultFormValues();
|
const defaultValues = getDefaultFormValues();
|
||||||
|
|
||||||
@@ -423,15 +452,24 @@ describe('getInstantFromDataQuery', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isExpressionQuery(model: unknown): model is ExpressionQuery {
|
||||||
|
return typeof model === 'object' && model !== null && 'type' in model;
|
||||||
|
}
|
||||||
|
|
||||||
describe('getDefaultExpressions', () => {
|
describe('getDefaultExpressions', () => {
|
||||||
it('should create a reduce expression as the first query', () => {
|
it('should create a reduce expression as the first query', () => {
|
||||||
const result = getDefaultExpressions('B', 'C');
|
const result = getDefaultExpressions('B', 'C');
|
||||||
const reduceQuery = result[0];
|
const reduceQuery = result[0];
|
||||||
const model = reduceQuery.model;
|
const { model } = reduceQuery;
|
||||||
|
|
||||||
expect(reduceQuery.refId).toBe('B');
|
expect(reduceQuery.refId).toBe('B');
|
||||||
expect(reduceQuery.datasourceUid).toBe(ExpressionDatasourceUID);
|
expect(reduceQuery.datasourceUid).toBe(ExpressionDatasourceUID);
|
||||||
expect(reduceQuery.queryType).toBe('');
|
expect(reduceQuery.queryType).toBe('expression');
|
||||||
|
|
||||||
|
if (!isExpressionQuery(model)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(model.type).toBe(ExpressionQueryType.reduce);
|
expect(model.type).toBe(ExpressionQueryType.reduce);
|
||||||
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
|
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
|
||||||
expect(model.reducer).toBe('last');
|
expect(model.reducer).toBe('last');
|
||||||
@@ -440,7 +478,11 @@ describe('getDefaultExpressions', () => {
|
|||||||
it('should create reduce expression with proper conditions structure', () => {
|
it('should create reduce expression with proper conditions structure', () => {
|
||||||
const result = getDefaultExpressions('B', 'C');
|
const result = getDefaultExpressions('B', 'C');
|
||||||
const reduceQuery = result[0];
|
const reduceQuery = result[0];
|
||||||
const model = reduceQuery.model;
|
const { model } = reduceQuery;
|
||||||
|
|
||||||
|
if (!isExpressionQuery(model)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(model.conditions).toHaveLength(1);
|
expect(model.conditions).toHaveLength(1);
|
||||||
expect(model.expression).toBe('A');
|
expect(model.expression).toBe('A');
|
||||||
@@ -466,11 +508,16 @@ describe('getDefaultExpressions', () => {
|
|||||||
it('should create a threshold expression as the second query', () => {
|
it('should create a threshold expression as the second query', () => {
|
||||||
const result = getDefaultExpressions('B', 'C');
|
const result = getDefaultExpressions('B', 'C');
|
||||||
const thresholdQuery = result[1];
|
const thresholdQuery = result[1];
|
||||||
const model = thresholdQuery.model;
|
const { model } = thresholdQuery;
|
||||||
|
|
||||||
expect(thresholdQuery.refId).toBe('C');
|
expect(thresholdQuery.refId).toBe('C');
|
||||||
expect(thresholdQuery.datasourceUid).toBe(ExpressionDatasourceUID);
|
expect(thresholdQuery.datasourceUid).toBe(ExpressionDatasourceUID);
|
||||||
expect(thresholdQuery.queryType).toBe('');
|
expect(thresholdQuery.queryType).toBe('expression');
|
||||||
|
|
||||||
|
if (!isExpressionQuery(model)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(model.type).toBe(ExpressionQueryType.threshold);
|
expect(model.type).toBe(ExpressionQueryType.threshold);
|
||||||
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
|
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
|
||||||
});
|
});
|
||||||
@@ -478,7 +525,11 @@ describe('getDefaultExpressions', () => {
|
|||||||
it('should create threshold expression with proper conditions structure', () => {
|
it('should create threshold expression with proper conditions structure', () => {
|
||||||
const result = getDefaultExpressions('B', 'C');
|
const result = getDefaultExpressions('B', 'C');
|
||||||
const thresholdQuery = result[1];
|
const thresholdQuery = result[1];
|
||||||
const model = thresholdQuery.model;
|
const { model } = thresholdQuery;
|
||||||
|
|
||||||
|
if (!isExpressionQuery(model)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(model.conditions).toHaveLength(1);
|
expect(model.conditions).toHaveLength(1);
|
||||||
expect(model.conditions?.[0]).toEqual({
|
expect(model.conditions?.[0]).toEqual({
|
||||||
@@ -491,7 +542,7 @@ describe('getDefaultExpressions', () => {
|
|||||||
type: 'and',
|
type: 'and',
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
params: [],
|
params: ['C'],
|
||||||
},
|
},
|
||||||
reducer: {
|
reducer: {
|
||||||
params: [],
|
params: [],
|
||||||
@@ -503,7 +554,11 @@ describe('getDefaultExpressions', () => {
|
|||||||
it('should reference the reduce expression in the threshold expression', () => {
|
it('should reference the reduce expression in the threshold expression', () => {
|
||||||
const result = getDefaultExpressions('B', 'C');
|
const result = getDefaultExpressions('B', 'C');
|
||||||
const thresholdQuery = result[1];
|
const thresholdQuery = result[1];
|
||||||
const model = thresholdQuery.model;
|
const { model } = thresholdQuery;
|
||||||
|
|
||||||
|
if (!isExpressionQuery(model)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(model.expression).toBe('B');
|
expect(model.expression).toBe('B');
|
||||||
});
|
});
|
||||||
@@ -513,6 +568,10 @@ describe('getDefaultExpressions', () => {
|
|||||||
const reduceModel = result[0].model;
|
const reduceModel = result[0].model;
|
||||||
const thresholdModel = result[1].model;
|
const thresholdModel = result[1].model;
|
||||||
|
|
||||||
|
if (!isExpressionQuery(reduceModel) || !isExpressionQuery(thresholdModel)) {
|
||||||
|
throw new Error('Expected ExpressionQuery');
|
||||||
|
}
|
||||||
|
|
||||||
expect(result[0].refId).toBe('X');
|
expect(result[0].refId).toBe('X');
|
||||||
expect(reduceModel.refId).toBe('X');
|
expect(reduceModel.refId).toBe('X');
|
||||||
expect(reduceModel.conditions?.[0].query.params).toEqual([]);
|
expect(reduceModel.conditions?.[0].query.params).toEqual([]);
|
||||||
|
|||||||
@@ -559,14 +559,85 @@ export const getDefaultRecordingRulesQueries = (
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultExpressions = (...refIds: [string, string]) => {
|
export const getDefaultExpressions = (...refIds: [string, string] | [string, string, string]): AlertQuery[] => {
|
||||||
const refOne = refIds[0];
|
const refOne = refIds[0];
|
||||||
const refTwo = refIds[1];
|
const refTwo = refIds[1];
|
||||||
|
// If a third parameter is provided, use it as the source query refId, otherwise default to 'A'
|
||||||
|
const sourceRefId = refIds.length === 3 ? refIds[2] : 'A';
|
||||||
|
|
||||||
const reduceQuery = getDefaultReduceExpression({ inputRefId: 'A', reduceRefId: refOne });
|
const reduceExpression: ExpressionQuery = {
|
||||||
const thresholdQuery = getDefaultThresholdExpression({ inputRefId: refOne, thresholdRefId: refTwo });
|
refId: refIds[0],
|
||||||
|
type: ExpressionQueryType.reduce,
|
||||||
|
datasource: {
|
||||||
|
uid: ExpressionDatasourceUID,
|
||||||
|
type: ExpressionDatasourceRef.type,
|
||||||
|
},
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
evaluator: {
|
||||||
|
params: [],
|
||||||
|
type: EvalFunction.IsAbove,
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: 'and',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
|
reducer: {
|
||||||
|
params: [],
|
||||||
|
type: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reducer: 'last',
|
||||||
|
expression: sourceRefId,
|
||||||
|
};
|
||||||
|
|
||||||
return [reduceQuery, thresholdQuery] as const;
|
const thresholdExpression: ExpressionQuery = {
|
||||||
|
refId: refTwo,
|
||||||
|
type: ExpressionQueryType.threshold,
|
||||||
|
datasource: {
|
||||||
|
uid: ExpressionDatasourceUID,
|
||||||
|
type: ExpressionDatasourceRef.type,
|
||||||
|
},
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
evaluator: {
|
||||||
|
params: [0],
|
||||||
|
type: EvalFunction.IsAbove,
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: 'and',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
params: [refTwo],
|
||||||
|
},
|
||||||
|
reducer: {
|
||||||
|
params: [],
|
||||||
|
type: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expression: refOne,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
refId: refOne,
|
||||||
|
datasourceUid: ExpressionDatasourceUID,
|
||||||
|
queryType: 'expression',
|
||||||
|
model: reduceExpression,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refId: refTwo,
|
||||||
|
datasourceUid: ExpressionDatasourceUID,
|
||||||
|
queryType: 'expression',
|
||||||
|
model: thresholdExpression,
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultExpressionsForRecording = (refOne: string): Array<AlertQuery<ExpressionQuery>> => {
|
const getDefaultExpressionsForRecording = (refOne: string): Array<AlertQuery<ExpressionQuery>> => {
|
||||||
@@ -610,95 +681,6 @@ const getDefaultExpressionsForRecording = (refOne: string): Array<AlertQuery<Exp
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultReduceExpression({
|
|
||||||
inputRefId,
|
|
||||||
reduceRefId,
|
|
||||||
}: {
|
|
||||||
inputRefId: string;
|
|
||||||
reduceRefId: string;
|
|
||||||
}): AlertQuery<ExpressionQuery> {
|
|
||||||
const reduceExpression: ExpressionQuery = {
|
|
||||||
refId: reduceRefId,
|
|
||||||
type: ExpressionQueryType.reduce,
|
|
||||||
datasource: {
|
|
||||||
uid: ExpressionDatasourceUID,
|
|
||||||
type: ExpressionDatasourceRef.type,
|
|
||||||
},
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
type: 'query',
|
|
||||||
evaluator: {
|
|
||||||
params: [],
|
|
||||||
type: EvalFunction.IsAbove,
|
|
||||||
},
|
|
||||||
operator: {
|
|
||||||
type: 'and',
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
params: [],
|
|
||||||
},
|
|
||||||
reducer: {
|
|
||||||
params: [],
|
|
||||||
type: 'last',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reducer: 'last',
|
|
||||||
expression: inputRefId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
refId: reduceRefId,
|
|
||||||
datasourceUid: ExpressionDatasourceUID,
|
|
||||||
queryType: '',
|
|
||||||
model: reduceExpression,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultThresholdExpression({
|
|
||||||
inputRefId,
|
|
||||||
thresholdRefId,
|
|
||||||
}: {
|
|
||||||
inputRefId: string;
|
|
||||||
thresholdRefId: string;
|
|
||||||
}): AlertQuery<ExpressionQuery> {
|
|
||||||
const thresholdExpression: ExpressionQuery = {
|
|
||||||
refId: thresholdRefId,
|
|
||||||
type: ExpressionQueryType.threshold,
|
|
||||||
datasource: {
|
|
||||||
uid: ExpressionDatasourceUID,
|
|
||||||
type: ExpressionDatasourceRef.type,
|
|
||||||
},
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
type: 'query',
|
|
||||||
evaluator: {
|
|
||||||
params: [0],
|
|
||||||
type: EvalFunction.IsAbove,
|
|
||||||
},
|
|
||||||
operator: {
|
|
||||||
type: 'and',
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
params: [],
|
|
||||||
},
|
|
||||||
reducer: {
|
|
||||||
params: [],
|
|
||||||
type: 'last',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
expression: inputRefId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
refId: thresholdRefId,
|
|
||||||
datasourceUid: ExpressionDatasourceUID,
|
|
||||||
queryType: '',
|
|
||||||
model: thresholdExpression,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataQueriesToGrafanaQueries = async (
|
const dataQueriesToGrafanaQueries = async (
|
||||||
queries: DataQuery[],
|
queries: DataQuery[],
|
||||||
relativeTimeRange: RelativeTimeRange,
|
relativeTimeRange: RelativeTimeRange,
|
||||||
@@ -783,24 +765,15 @@ export const panelToRuleFormValues = async (
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastQuery = queries.at(-1);
|
// Add default expression queries if they don't exist
|
||||||
if (!lastQuery) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||||
const reduceExpression = getDefaultReduceExpression({
|
// Get the last data query's refId to use as the source for the reduce expression
|
||||||
inputRefId: lastQuery.refId,
|
const lastDataQueryRefId = queries[queries.length - 1].refId;
|
||||||
reduceRefId: getNextRefId(queries),
|
const reduceRefId = getNextRefId(queries);
|
||||||
});
|
const queriesWithReduce = [...queries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
|
||||||
queries.push(reduceExpression);
|
const thresholdRefId = getNextRefId(queriesWithReduce);
|
||||||
|
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
|
||||||
const thresholdExpression = getDefaultThresholdExpression({
|
queries.push(...expressions);
|
||||||
inputRefId: reduceExpression.refId,
|
|
||||||
thresholdRefId: getNextRefId(queries),
|
|
||||||
});
|
|
||||||
|
|
||||||
queries.push(thresholdExpression);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { folderTitle, folderUid } = dashboard.meta;
|
const { folderTitle, folderUid } = dashboard.meta;
|
||||||
@@ -870,24 +843,15 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<P
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastQuery = grafanaQueries.at(-1);
|
// Add default expression queries if they don't exist
|
||||||
if (!lastQuery) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||||
const reduceExpression = getDefaultReduceExpression({
|
// Get the last data query's refId to use as the source for the reduce expression
|
||||||
inputRefId: lastQuery.refId,
|
const lastDataQueryRefId = grafanaQueries[grafanaQueries.length - 1].refId;
|
||||||
reduceRefId: getNextRefId(grafanaQueries),
|
const reduceRefId = getNextRefId(grafanaQueries);
|
||||||
});
|
const queriesWithReduce = [...grafanaQueries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
|
||||||
grafanaQueries.push(reduceExpression);
|
const thresholdRefId = getNextRefId(queriesWithReduce);
|
||||||
|
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
|
||||||
const thresholdExpression = getDefaultThresholdExpression({
|
grafanaQueries.push(...expressions);
|
||||||
inputRefId: reduceExpression.refId,
|
|
||||||
thresholdRefId: getNextRefId(grafanaQueries),
|
|
||||||
});
|
|
||||||
|
|
||||||
grafanaQueries.push(thresholdExpression);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { folderTitle, folderUid } = dashboard.state.meta;
|
const { folderTitle, folderUid } = dashboard.state.meta;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom-v5-compat';
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { urlUtil } from '@grafana/data';
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { locationService, logInfo } from '@grafana/runtime';
|
|
||||||
import { VizPanel } from '@grafana/scenes';
|
import { VizPanel } from '@grafana/scenes';
|
||||||
import { Alert, Button } from '@grafana/ui';
|
import { Alert, Button } from '@grafana/ui';
|
||||||
import { LogMessages } from 'app/features/alerting/unified/Analytics';
|
import { LogMessages, logInfo } from 'app/features/alerting/unified/Analytics';
|
||||||
|
import { AlertRuleDrawerForm } from 'app/features/alerting/unified/components/AlertRuleDrawerForm';
|
||||||
|
import { createPanelAlertRuleNavigation } from 'app/features/alerting/unified/utils/navigation';
|
||||||
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
|
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
|
||||||
|
|
||||||
interface ScenesNewRuleFromPanelButtonProps {
|
interface ScenesNewRuleFromPanelButtonProps {
|
||||||
@@ -17,6 +18,7 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]);
|
const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -42,22 +44,30 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = async () => {
|
const { onContinueInAlertingFromDrawer } = createPanelAlertRuleNavigation(
|
||||||
logInfo(LogMessages.alertRuleFromPanel);
|
() => scenesPanelToRuleFormValues(panel),
|
||||||
|
location
|
||||||
const updateToDateFormValues = await scenesPanelToRuleFormValues(panel);
|
);
|
||||||
|
|
||||||
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
|
|
||||||
defaults: JSON.stringify(updateToDateFormValues),
|
|
||||||
returnTo: location.pathname + location.search,
|
|
||||||
});
|
|
||||||
|
|
||||||
locationService.push(ruleFormUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button icon="bell" onClick={onClick} className={className} data-testid="create-alert-rule-button">
|
<>
|
||||||
<Trans i18nKey="dashboard-scene.scenes-new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
|
<Button
|
||||||
</Button>
|
icon="bell"
|
||||||
|
className={className}
|
||||||
|
data-testid="create-alert-rule-button-drawer"
|
||||||
|
onClick={() => {
|
||||||
|
logInfo(LogMessages.alertRuleFromPanel);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
|
||||||
|
</Button>
|
||||||
|
<AlertRuleDrawerForm
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
onContinueInAlerting={onContinueInAlertingFromDrawer}
|
||||||
|
prefill={formValues ?? undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
@@ -39,9 +40,9 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
|
|||||||
new PanelDataTransformationsTab({ panelRef }),
|
new PanelDataTransformationsTab({ panelRef }),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (shouldShowAlertingTab(panel.state.pluginId)) {
|
// if (shouldShowAlertingTab(panel.state.pluginId)) {
|
||||||
tabs.push(new PanelDataAlertingTab({ panelRef }));
|
tabs.push(new PanelDataAlertingTab({ panelRef }));
|
||||||
}
|
// }
|
||||||
|
|
||||||
return new PanelDataPane({
|
return new PanelDataPane({
|
||||||
panelRef,
|
panelRef,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
|||||||
"refId": "B",
|
"refId": "B",
|
||||||
"type": "reduce",
|
"type": "reduce",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,7 +83,9 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
|||||||
"type": "and",
|
"type": "and",
|
||||||
},
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"params": [],
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"reducer": {
|
"reducer": {
|
||||||
"params": [],
|
"params": [],
|
||||||
@@ -100,7 +102,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
|||||||
"refId": "C",
|
"refId": "C",
|
||||||
"type": "threshold",
|
"type": "threshold",
|
||||||
},
|
},
|
||||||
"queryType": "",
|
"queryType": "expression",
|
||||||
"refId": "C",
|
"refId": "C",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
featureToggles: {
|
featureToggles: {
|
||||||
newVariables: false,
|
newVariables: false,
|
||||||
},
|
},
|
||||||
|
unifiedAlerting: {
|
||||||
|
minInterval: '10s',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
featureToggles: {
|
featureToggles: {
|
||||||
newVariables: false,
|
newVariables: false,
|
||||||
},
|
},
|
||||||
|
unifiedAlerting: {
|
||||||
|
minInterval: '10s',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
unifiedAlerting: {
|
||||||
|
minInterval: '10s',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,7 @@
|
|||||||
"metric-aria-label-metric": "metric",
|
"metric-aria-label-metric": "metric",
|
||||||
"metric-placeholder-recorded-metric": "Give the name of the new recorded metric",
|
"metric-placeholder-recorded-metric": "Give the name of the new recorded metric",
|
||||||
"placeholder-name": "Give your {{namePlaceholder}} a name",
|
"placeholder-name": "Give your {{namePlaceholder}} a name",
|
||||||
|
"recording-rule-pattern": "Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.",
|
||||||
"title-section": "Enter {{entityName}} name"
|
"title-section": "Enter {{entityName}} name"
|
||||||
},
|
},
|
||||||
"alert-rules": {
|
"alert-rules": {
|
||||||
@@ -858,6 +859,8 @@
|
|||||||
"export-all": "Export all",
|
"export-all": "Export all",
|
||||||
"learn-more": "Learn more",
|
"learn-more": "Learn more",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"refresh": "Refresh",
|
||||||
"search-by-matchers": "Search by matchers",
|
"search-by-matchers": "Search by matchers",
|
||||||
"titles": {
|
"titles": {
|
||||||
"notification-templates": "Notification Templates"
|
"notification-templates": "Notification Templates"
|
||||||
@@ -1804,6 +1807,8 @@
|
|||||||
"manual-and-automatic-routing": {
|
"manual-and-automatic-routing": {
|
||||||
"routing-options": {
|
"routing-options": {
|
||||||
"label": {
|
"label": {
|
||||||
|
"contact-point": "Contact point",
|
||||||
|
"notification-policy": "Notification policy",
|
||||||
"select-contact-point": "Select contact point",
|
"select-contact-point": "Select contact point",
|
||||||
"use-notification-policy": "Use notification policy"
|
"use-notification-policy": "Use notification policy"
|
||||||
}
|
}
|
||||||
@@ -2758,6 +2763,7 @@
|
|||||||
"title-the-selected-alertmanager-has-no-configuration": "The selected Alertmanager has no configuration"
|
"title-the-selected-alertmanager-has-no-configuration": "The selected Alertmanager has no configuration"
|
||||||
},
|
},
|
||||||
"simple-condition-editor": {
|
"simple-condition-editor": {
|
||||||
|
"aria-label-reducer": "Select reducer function",
|
||||||
"label-of-query": "OF QUERY",
|
"label-of-query": "OF QUERY",
|
||||||
"label-when": "WHEN",
|
"label-when": "WHEN",
|
||||||
"label-when-query": "WHEN QUERY"
|
"label-when-query": "WHEN QUERY"
|
||||||
@@ -2765,6 +2771,47 @@
|
|||||||
"simpleCondition": {
|
"simpleCondition": {
|
||||||
"alertCondition": "Alert condition"
|
"alertCondition": "Alert condition"
|
||||||
},
|
},
|
||||||
|
"simplified": {
|
||||||
|
"condition": {
|
||||||
|
"title": "Condition"
|
||||||
|
},
|
||||||
|
"continue-in-alerting": "Continue in Alerting",
|
||||||
|
"create": "Create",
|
||||||
|
"evaluation": {
|
||||||
|
"immediate-warning": "Immediate firing might lead to unnecessary alerts being sent for temporary issues"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"contact-point-selected": "Notifications for firing alerts are routed to a selected contact point.",
|
||||||
|
"description": {
|
||||||
|
"aria-label": "Description",
|
||||||
|
"label": "Description (optional)",
|
||||||
|
"placeholder": "Enter a description of what the alert rule does…"
|
||||||
|
},
|
||||||
|
"manual-routing": {
|
||||||
|
"aria": "Toggle manual routing"
|
||||||
|
},
|
||||||
|
"policy-selected": "Notifications for firing alerts are routed to contact points based on matching labels and the notification policy tree.",
|
||||||
|
"recipient": {
|
||||||
|
"label": "Recipient"
|
||||||
|
},
|
||||||
|
"refresh-error": "Failed to refresh contact points",
|
||||||
|
"runbook-url": {
|
||||||
|
"aria-label": "Runbook URL",
|
||||||
|
"invalid-format": "Invalid URL format. Please enter a valid URL.",
|
||||||
|
"invalid-protocol": "Invalid URL protocol. Please use http or https.",
|
||||||
|
"label": "Runbook URL (optional)",
|
||||||
|
"placeholder": "Enter the webpage where you keep your runbook for the alert…"
|
||||||
|
},
|
||||||
|
"select-contact-point": "Select a contact point...",
|
||||||
|
"summary": {
|
||||||
|
"aria-label": "Summary",
|
||||||
|
"label": "Summary (optional)",
|
||||||
|
"placeholder": "Enter a summary of what happened and why…"
|
||||||
|
},
|
||||||
|
"title": "Notification"
|
||||||
|
},
|
||||||
|
"rule-definition": "Rule Definition"
|
||||||
|
},
|
||||||
"smart-alert-type-detector": {
|
"smart-alert-type-detector": {
|
||||||
"data-source-managed": "Data source-managed",
|
"data-source-managed": "Data source-managed",
|
||||||
"data-sourcemanaged-alert-rules": "Data source-managed alert rules",
|
"data-sourcemanaged-alert-rules": "Data source-managed alert rules",
|
||||||
|
|||||||
Reference in New Issue
Block a user