Compare commits

...

33 Commits

Author SHA1 Message Date
laurenashleigh
625292750e temporarily remove feature toggle 2025-12-08 15:27:00 +00:00
laurenashleigh
cc31663b60 fix FE test 2025-12-08 14:51:36 +00:00
laurenashleigh
704df95ff3 update snapshots 2025-12-08 14:51:36 +00:00
laurenashleigh
2f24d9c0bd update snapshots and test 2025-12-08 14:51:35 +00:00
laurenashleigh
43fff90083 remove unused functions 2025-12-08 14:51:34 +00:00
laurenashleigh
750dc0da7e update snapshot 2025-12-08 14:51:34 +00:00
laurenashleigh
7f2db9e714 add tests & code comments 2025-12-08 14:51:33 +00:00
laurenashleigh
c6fc5db7d5 fix form reset & prefill behaviour 2025-12-08 14:51:32 +00:00
laurenashleigh
967e59d436 update snapshots 2025-12-08 14:51:31 +00:00
laurenashleigh
840e3d4f16 generate feature toggles 2025-12-08 14:51:30 +00:00
laurenashleigh
62c7f8cf0f prune outdated ESLint suppressions 2025-12-08 14:51:30 +00:00
laurenashleigh
294ea6e2e2 remove unused eslint comment 2025-12-08 14:51:29 +00:00
laurenashleigh
145e28f3dc remove type assertion 2025-12-08 14:51:28 +00:00
laurenashleigh
9a4ff7401a fix incomplete url scheme check 2025-12-08 14:51:27 +00:00
laurenashleigh
11046afc60 translations 2025-12-08 14:51:26 +00:00
laurenashleigh
8c35fb22d8 Fix alert rule drawer condition handling and TypeScript errors 2025-12-08 14:51:26 +00:00
laurenashleigh
20cc0e9b31 update logic for expression parsing from panel to alerting 2025-12-08 14:51:25 +00:00
laurenashleigh
2d87411717 Fix tests: Handle undefined config.unifiedAlerting in tests 2025-12-08 14:51:24 +00:00
laurenashleigh
4dc547b65b add feature toggle to auto gen files 2025-12-08 14:51:24 +00:00
laurenashleigh
9364c35e3e fix issues with expression values in alert rule form page from dashboard 2025-12-08 14:51:23 +00:00
laurenashleigh
97a648d8dd fix values persisting when continuing in alerting 2025-12-08 14:51:23 +00:00
laurenashleigh
7d96a5a701 Update UI to match designs 2025-12-08 14:51:22 +00:00
laurenashleigh
0d6522d1dc Use radio button for contact point - notification policy toggle 2025-12-08 14:51:22 +00:00
laurenashleigh
6396402b04 refactoring 2025-12-08 14:51:22 +00:00
laurenashleigh
0d8893ad20 enable toggling between manual routing 2025-12-08 14:51:21 +00:00
laurenashleigh
30ae184497 replace evaluation inputs with group dropdown 2025-12-08 14:51:21 +00:00
laurenashleigh
88963e846f update rule definition section UI 2025-12-08 14:51:20 +00:00
laurenashleigh
32ee91ebcd persist form values after redirect to rule page 2025-12-08 14:51:20 +00:00
laurenashleigh
1f5d0f7d6d refactoring and fixing 2025-12-08 14:51:19 +00:00
laurenashleigh
c335ad0571 add notification section to panel 2025-12-08 14:51:19 +00:00
laurenashleigh
5875109661 Add Condition section to panel 2025-12-08 14:51:19 +00:00
laurenashleigh
485ac51021 Add rule definition section to panel 2025-12-08 14:51:18 +00:00
laurenashleigh
88bd3566c4 Alerting: allow create alert rule from dashboard panel drawer 2025-12-08 14:51:17 +00:00
36 changed files with 1954 additions and 213 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
74 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
75 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
76 alertingUIUseFullyCompatBackendFilters experimental @grafana/alerting-squad false false false
77 createAlertRuleFromPanel experimental @grafana/alerting-squad false false true
78 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
79 annotationPermissionUpdate GA @grafana/identity-access-team false false false
80 dashboardSceneForViewers GA @grafana/dashboards-squad false false true

View File

@@ -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",

View File

@@ -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",
},
],

View File

@@ -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!');
});
});
});
});

View File

@@ -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),
}),
};
}

View File

@@ -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' }),
};
}

View File

@@ -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,
}),
};
}

View File

@@ -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%',
}),
};
}

View File

@@ -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>

View File

@@ -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({

View File

@@ -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}
/>
</>
);
};

View File

@@ -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),
}),
};
}

View File

@@ -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[] = [

View File

@@ -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>
);
}

View File

@@ -439,7 +439,7 @@ export function GrafanaEvaluationBehaviorStep({
);
}
function EvaluationGroupCreationModal({
export function EvaluationGroupCreationModal({
onClose,
onCreate,
groupfoldersForGrafana,

View File

@@ -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={

View File

@@ -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",
},
],

View File

@@ -97,7 +97,7 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps
</Stack>
);
}
function LinkToContactPoints() {
export function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
return (
<TextLink

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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",
},
],

View File

@@ -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>) {

View File

@@ -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 {

View File

@@ -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,
};
};

View File

@@ -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([]);

View File

@@ -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;

View File

@@ -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}
/>
</>
);
};

View File

@@ -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,

View File

@@ -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",
},
],

View File

@@ -48,6 +48,9 @@ jest.mock('@grafana/runtime', () => ({
featureToggles: {
newVariables: false,
},
unifiedAlerting: {
minInterval: '10s',
},
},
}));

View File

@@ -40,6 +40,9 @@ jest.mock('@grafana/runtime', () => ({
featureToggles: {
newVariables: false,
},
unifiedAlerting: {
minInterval: '10s',
},
},
}));

View File

@@ -42,6 +42,9 @@ jest.mock('@grafana/runtime', () => ({
},
},
},
unifiedAlerting: {
minInterval: '10s',
},
},
}));

View File

@@ -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",