mirror of
https://github.com/grafana/grafana.git
synced 2025-12-23 13:14:35 +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
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -333,6 +333,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
alertmanagerRemotePrimary?: boolean;
|
||||
|
||||
@@ -534,6 +534,13 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "createAlertRuleFromPanel",
|
||||
Description: "Enables creating alert rules from a panel using a drawer UI",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "alertmanagerRemotePrimary",
|
||||
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
|
||||
alertingUIUseBackendFilters,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
|
||||
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
|
||||
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
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"name": "dashboardDisableSchemaValidationV1",
|
||||
|
||||
@@ -70,7 +70,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -105,7 +105,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"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)}
|
||||
type="button"
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||
>
|
||||
<Trans i18nKey="alerting.create-new-folder.new-folder">New folder</Trans>
|
||||
|
||||
@@ -24,6 +24,20 @@ jest.mock('react-use', () => ({
|
||||
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', () => {
|
||||
it('Sends log info when creating an alert rule from a panel', async () => {
|
||||
const panel = new PanelModel({
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { urlUtil } from '@grafana/data';
|
||||
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 { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { LogMessages, logInfo } from '../../Analytics';
|
||||
import { AlertRuleDrawerForm } from '../../components/AlertRuleDrawerForm';
|
||||
import { createPanelAlertRuleNavigation } from '../../utils/navigation';
|
||||
import { panelToRuleFormValues } from '../../utils/rule-form';
|
||||
|
||||
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
|
||||
[panel, dashboard, templating]
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -54,20 +57,30 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) =
|
||||
);
|
||||
}
|
||||
|
||||
const ruleFormUrl = urlUtil.renderUrl('alerting/new', {
|
||||
defaults: JSON.stringify(formValues),
|
||||
returnTo: location.pathname + location.search,
|
||||
});
|
||||
const { onContinueInAlertingFromDrawer } = createPanelAlertRuleNavigation(
|
||||
() => panelToRuleFormValues(panel, dashboard),
|
||||
location
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
icon="bell"
|
||||
onClick={() => logInfo(LogMessages.alertRuleFromPanel)}
|
||||
href={ruleFormUrl}
|
||||
className={className}
|
||||
data-testid="create-alert-rule-button"
|
||||
>
|
||||
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
|
||||
</LinkButton>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
const MIN_INTERVAl = config.unifiedAlerting.minInterval ?? '10s';
|
||||
const MIN_INTERVAl = config.unifiedAlerting?.minInterval ?? '10s';
|
||||
export const getEvaluationGroupOptions = (minInterval = MIN_INTERVAl) => {
|
||||
const MIN_OPTIONS_TO_SHOW = 8;
|
||||
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,
|
||||
onCreate,
|
||||
groupfoldersForGrafana,
|
||||
|
||||
@@ -250,7 +250,7 @@ function AutomaticRooting({ alertUid }: AutomaticRootingProps) {
|
||||
}
|
||||
|
||||
// Auxiliar components to build the texts and descriptions in the NotificationsStep
|
||||
function NeedHelpInfoForNotificationPolicy() {
|
||||
export function NeedHelpInfoForNotificationPolicy() {
|
||||
return (
|
||||
<NeedHelpInfo
|
||||
contentText={
|
||||
|
||||
@@ -97,7 +97,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -115,7 +115,9 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -132,7 +134,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
@@ -263,7 +265,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -281,7 +283,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -298,7 +302,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
@@ -432,7 +436,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -450,7 +454,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -467,7 +473,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
@@ -604,7 +610,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -622,7 +628,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -639,7 +647,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
@@ -773,7 +781,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -791,7 +799,9 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -808,7 +818,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
function LinkToContactPoints() {
|
||||
export function LinkToContactPoints() {
|
||||
const hrefToContactPoints = '/alerting/notifications';
|
||||
return (
|
||||
<TextLink
|
||||
|
||||
@@ -39,11 +39,12 @@ function mapLabelsToOptions(
|
||||
}
|
||||
|
||||
export interface LabelsInRuleProps {
|
||||
labels: Array<{ key: string; value: string }>;
|
||||
labels: Array<{ key: string; value: string }> | undefined | null;
|
||||
}
|
||||
|
||||
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) {
|
||||
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",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -117,7 +117,9 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -134,7 +136,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -251,10 +251,12 @@ export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormVa
|
||||
parsedRule = alertingAlertRuleFormSchema.parse(rule);
|
||||
}
|
||||
|
||||
return revealHiddenQueries({
|
||||
...getDefaultFormValues(rule.type),
|
||||
...parsedRule,
|
||||
});
|
||||
return setQueryEditorSettings(
|
||||
revealHiddenQueries({
|
||||
...getDefaultFormValues(rule.type),
|
||||
...parsedRule,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
|
||||
|
||||
@@ -27,8 +27,34 @@ export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
|
||||
// data queries only
|
||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
|
||||
// expression queries only
|
||||
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
|
||||
// expression queries only - but filter out invalid ones that don't have a type field
|
||||
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);
|
||||
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 { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { LogMessages } from '../Analytics';
|
||||
import { createReturnTo } from '../hooks/useReturnTo';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
|
||||
import { stringifyIdentifier } from './rule-id';
|
||||
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 { ExpressionDatasourceUID, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import {
|
||||
AlertDataQuery,
|
||||
@@ -52,6 +52,35 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('should create a reduce expression as the first query', () => {
|
||||
const result = getDefaultExpressions('B', 'C');
|
||||
const reduceQuery = result[0];
|
||||
const model = reduceQuery.model;
|
||||
const { model } = reduceQuery;
|
||||
|
||||
expect(reduceQuery.refId).toBe('B');
|
||||
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.datasource?.uid).toBe(ExpressionDatasourceUID);
|
||||
expect(model.reducer).toBe('last');
|
||||
@@ -440,7 +478,11 @@ describe('getDefaultExpressions', () => {
|
||||
it('should create reduce expression with proper conditions structure', () => {
|
||||
const result = getDefaultExpressions('B', 'C');
|
||||
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.expression).toBe('A');
|
||||
@@ -466,11 +508,16 @@ describe('getDefaultExpressions', () => {
|
||||
it('should create a threshold expression as the second query', () => {
|
||||
const result = getDefaultExpressions('B', 'C');
|
||||
const thresholdQuery = result[1];
|
||||
const model = thresholdQuery.model;
|
||||
const { model } = thresholdQuery;
|
||||
|
||||
expect(thresholdQuery.refId).toBe('C');
|
||||
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.datasource?.uid).toBe(ExpressionDatasourceUID);
|
||||
});
|
||||
@@ -478,7 +525,11 @@ describe('getDefaultExpressions', () => {
|
||||
it('should create threshold expression with proper conditions structure', () => {
|
||||
const result = getDefaultExpressions('B', 'C');
|
||||
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?.[0]).toEqual({
|
||||
@@ -491,7 +542,7 @@ describe('getDefaultExpressions', () => {
|
||||
type: 'and',
|
||||
},
|
||||
query: {
|
||||
params: [],
|
||||
params: ['C'],
|
||||
},
|
||||
reducer: {
|
||||
params: [],
|
||||
@@ -503,7 +554,11 @@ describe('getDefaultExpressions', () => {
|
||||
it('should reference the reduce expression in the threshold expression', () => {
|
||||
const result = getDefaultExpressions('B', 'C');
|
||||
const thresholdQuery = result[1];
|
||||
const model = thresholdQuery.model;
|
||||
const { model } = thresholdQuery;
|
||||
|
||||
if (!isExpressionQuery(model)) {
|
||||
throw new Error('Expected ExpressionQuery');
|
||||
}
|
||||
|
||||
expect(model.expression).toBe('B');
|
||||
});
|
||||
@@ -513,6 +568,10 @@ describe('getDefaultExpressions', () => {
|
||||
const reduceModel = result[0].model;
|
||||
const thresholdModel = result[1].model;
|
||||
|
||||
if (!isExpressionQuery(reduceModel) || !isExpressionQuery(thresholdModel)) {
|
||||
throw new Error('Expected ExpressionQuery');
|
||||
}
|
||||
|
||||
expect(result[0].refId).toBe('X');
|
||||
expect(reduceModel.refId).toBe('X');
|
||||
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 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 thresholdQuery = getDefaultThresholdExpression({ inputRefId: refOne, thresholdRefId: refTwo });
|
||||
const reduceExpression: ExpressionQuery = {
|
||||
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>> => {
|
||||
@@ -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 (
|
||||
queries: DataQuery[],
|
||||
relativeTimeRange: RelativeTimeRange,
|
||||
@@ -783,24 +765,15 @@ export const panelToRuleFormValues = async (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastQuery = queries.at(-1);
|
||||
if (!lastQuery) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Add default expression queries if they don't exist
|
||||
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||
const reduceExpression = getDefaultReduceExpression({
|
||||
inputRefId: lastQuery.refId,
|
||||
reduceRefId: getNextRefId(queries),
|
||||
});
|
||||
queries.push(reduceExpression);
|
||||
|
||||
const thresholdExpression = getDefaultThresholdExpression({
|
||||
inputRefId: reduceExpression.refId,
|
||||
thresholdRefId: getNextRefId(queries),
|
||||
});
|
||||
|
||||
queries.push(thresholdExpression);
|
||||
// Get the last data query's refId to use as the source for the reduce expression
|
||||
const lastDataQueryRefId = queries[queries.length - 1].refId;
|
||||
const reduceRefId = getNextRefId(queries);
|
||||
const queriesWithReduce = [...queries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
|
||||
const thresholdRefId = getNextRefId(queriesWithReduce);
|
||||
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
|
||||
queries.push(...expressions);
|
||||
}
|
||||
|
||||
const { folderTitle, folderUid } = dashboard.meta;
|
||||
@@ -870,24 +843,15 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<P
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastQuery = grafanaQueries.at(-1);
|
||||
if (!lastQuery) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Add default expression queries if they don't exist
|
||||
if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||
const reduceExpression = getDefaultReduceExpression({
|
||||
inputRefId: lastQuery.refId,
|
||||
reduceRefId: getNextRefId(grafanaQueries),
|
||||
});
|
||||
grafanaQueries.push(reduceExpression);
|
||||
|
||||
const thresholdExpression = getDefaultThresholdExpression({
|
||||
inputRefId: reduceExpression.refId,
|
||||
thresholdRefId: getNextRefId(grafanaQueries),
|
||||
});
|
||||
|
||||
grafanaQueries.push(thresholdExpression);
|
||||
// Get the last data query's refId to use as the source for the reduce expression
|
||||
const lastDataQueryRefId = grafanaQueries[grafanaQueries.length - 1].refId;
|
||||
const reduceRefId = getNextRefId(grafanaQueries);
|
||||
const queriesWithReduce = [...grafanaQueries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
|
||||
const thresholdRefId = getNextRefId(queriesWithReduce);
|
||||
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
|
||||
grafanaQueries.push(...expressions);
|
||||
}
|
||||
|
||||
const { folderTitle, folderUid } = dashboard.state.meta;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { urlUtil } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { locationService, logInfo } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
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';
|
||||
|
||||
interface ScenesNewRuleFromPanelButtonProps {
|
||||
@@ -17,6 +18,7 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
|
||||
const location = useLocation();
|
||||
|
||||
const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -42,22 +44,30 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
logInfo(LogMessages.alertRuleFromPanel);
|
||||
|
||||
const updateToDateFormValues = await scenesPanelToRuleFormValues(panel);
|
||||
|
||||
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
|
||||
defaults: JSON.stringify(updateToDateFormValues),
|
||||
returnTo: location.pathname + location.search,
|
||||
});
|
||||
|
||||
locationService.push(ruleFormUrl);
|
||||
};
|
||||
const { onContinueInAlertingFromDrawer } = createPanelAlertRuleNavigation(
|
||||
() => scenesPanelToRuleFormValues(panel),
|
||||
location
|
||||
);
|
||||
|
||||
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 { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -39,9 +40,9 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
|
||||
new PanelDataTransformationsTab({ panelRef }),
|
||||
];
|
||||
|
||||
if (shouldShowAlertingTab(panel.state.pluginId)) {
|
||||
tabs.push(new PanelDataAlertingTab({ panelRef }));
|
||||
}
|
||||
// if (shouldShowAlertingTab(panel.state.pluginId)) {
|
||||
tabs.push(new PanelDataAlertingTab({ panelRef }));
|
||||
// }
|
||||
|
||||
return new PanelDataPane({
|
||||
panelRef,
|
||||
|
||||
@@ -65,7 +65,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "B",
|
||||
},
|
||||
{
|
||||
@@ -83,7 +83,9 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
@@ -100,7 +102,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"queryType": "expression",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -48,6 +48,9 @@ jest.mock('@grafana/runtime', () => ({
|
||||
featureToggles: {
|
||||
newVariables: false,
|
||||
},
|
||||
unifiedAlerting: {
|
||||
minInterval: '10s',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ jest.mock('@grafana/runtime', () => ({
|
||||
featureToggles: {
|
||||
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-placeholder-recorded-metric": "Give the name of the new recorded metric",
|
||||
"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"
|
||||
},
|
||||
"alert-rules": {
|
||||
@@ -858,6 +859,8 @@
|
||||
"export-all": "Export all",
|
||||
"learn-more": "Learn more",
|
||||
"loading": "Loading...",
|
||||
"optional": "(optional)",
|
||||
"refresh": "Refresh",
|
||||
"search-by-matchers": "Search by matchers",
|
||||
"titles": {
|
||||
"notification-templates": "Notification Templates"
|
||||
@@ -1804,6 +1807,8 @@
|
||||
"manual-and-automatic-routing": {
|
||||
"routing-options": {
|
||||
"label": {
|
||||
"contact-point": "Contact point",
|
||||
"notification-policy": "Notification policy",
|
||||
"select-contact-point": "Select contact point",
|
||||
"use-notification-policy": "Use notification policy"
|
||||
}
|
||||
@@ -2758,6 +2763,7 @@
|
||||
"title-the-selected-alertmanager-has-no-configuration": "The selected Alertmanager has no configuration"
|
||||
},
|
||||
"simple-condition-editor": {
|
||||
"aria-label-reducer": "Select reducer function",
|
||||
"label-of-query": "OF QUERY",
|
||||
"label-when": "WHEN",
|
||||
"label-when-query": "WHEN QUERY"
|
||||
@@ -2765,6 +2771,47 @@
|
||||
"simpleCondition": {
|
||||
"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": {
|
||||
"data-source-managed": "Data source-managed",
|
||||
"data-sourcemanaged-alert-rules": "Data source-managed alert rules",
|
||||
|
||||
Reference in New Issue
Block a user