Alerting: Create alertingAlertRuleFormSchema in restrictedGrafanaApis (#112794)

* create navigateToAlertFormSchemaApi in restrictedGrafanaApis and use it in formDefaults.ts

* update alertingSchemaApi

* fix test

* move alertingSchemaApi.ts and the alerting section in README.md to a new alerting folder

* move cloud rules form schema outside restrictedGrafanaApis

* remove cloud fields in schema and use values from existing enums for some fields

* refactor RestrictedGrafanaApisContextTypeInternalenrichment type adding a high level entry for alerting

* refactor: change naming and come back to one single level in the RestrictedGrafanaApisContextType

* update README.md
This commit is contained in:
Sonia Aguilar
2025-10-24 15:47:52 +02:00
committed by GitHub
parent 2c6c564c18
commit 681f27e6b3
6 changed files with 350 additions and 78 deletions
@@ -6,9 +6,15 @@ import {
useRestrictedGrafanaApis,
} from './RestrictedGrafanaApis';
// Mock schema for testing
const mockAlertRuleFormSchema = {
parse: jest.fn((data: unknown) => data),
safeParse: jest.fn((data: unknown) => ({ success: true, data })),
};
describe('RestrictedGrafanaApis', () => {
const apis: RestrictedGrafanaApisContextType = {
addPanel: () => {},
alertingAlertRuleFormSchema: mockAlertRuleFormSchema,
};
beforeEach(() => {
@@ -21,16 +27,15 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiAllowList={{ addPanel: ['grafana-test-app'] }}
apiAllowList={{ alertingAlertRuleFormSchema: ['grafana-test-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).toEqual(apis.addPanel);
expect(Object.keys(result.current)).toEqual(['addPanel']);
expect(result.current.alertingAlertRuleFormSchema).toEqual(apis.alertingAlertRuleFormSchema);
expect(Object.keys(result.current)).toEqual(['alertingAlertRuleFormSchema']);
});
it('should share an API if the plugin is allowed using a regexp', () => {
@@ -39,16 +44,15 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiAllowList={{ addPanel: [/^grafana-/] }}
apiAllowList={{ alertingAlertRuleFormSchema: [/^grafana-/] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).toEqual(apis.addPanel);
expect(Object.keys(result.current)).toEqual(['addPanel']);
expect(result.current.alertingAlertRuleFormSchema).toEqual(apis.alertingAlertRuleFormSchema);
expect(Object.keys(result.current)).toEqual(['alertingAlertRuleFormSchema']);
});
it('should not share an API if the plugin is not directly allowed and no allow regexp matches it', () => {
@@ -57,15 +61,14 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'myorg-test-app'}
apiAllowList={{ addPanel: [/^grafana-/] }}
apiAllowList={{ alertingAlertRuleFormSchema: [/^grafana-/] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).not.toBeDefined();
expect(result.current.alertingAlertRuleFormSchema).not.toBeDefined();
});
// Ideally the `allowList` and the `blockList` are not used together
@@ -75,17 +78,16 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiAllowList={{ addPanel: ['grafana-test-app'] }}
apiBlockList={{ addPanel: ['grafana-test-app'] }}
apiAllowList={{ alertingAlertRuleFormSchema: ['grafana-test-app'] }}
apiBlockList={{ alertingAlertRuleFormSchema: ['grafana-test-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).toEqual(apis.addPanel);
expect(Object.keys(result.current)).toEqual(['addPanel']);
expect(result.current.alertingAlertRuleFormSchema).toEqual(apis.alertingAlertRuleFormSchema);
expect(Object.keys(result.current)).toEqual(['alertingAlertRuleFormSchema']);
});
it('should share an API with allowed plugins (testing multiple plugins)', () => {
@@ -97,14 +99,13 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiAllowList={{ addPanel: ['grafana-test-app', 'grafana-assistant-app'] }}
apiAllowList={{ alertingAlertRuleFormSchema: ['grafana-test-app', 'grafana-assistant-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.result.current.addPanel).toEqual(apis.addPanel);
expect(result.result.current.alertingAlertRuleFormSchema).toEqual(apis.alertingAlertRuleFormSchema);
// 2. Second app
result = renderHook(() => useRestrictedGrafanaApis(), {
@@ -112,14 +113,13 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-assistant-app'}
apiAllowList={{ addPanel: ['grafana-test-app', 'grafana-assistant-app'] }}
apiAllowList={{ alertingAlertRuleFormSchema: ['grafana-test-app', 'grafana-assistant-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.result.current.addPanel).toEqual(apis.addPanel);
expect(result.result.current.alertingAlertRuleFormSchema).toEqual(apis.alertingAlertRuleFormSchema);
});
it('should not share APIs with plugins that are not allowed', () => {
@@ -128,15 +128,14 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-restricted-app'}
apiAllowList={{ addPanel: ['grafana-authorised-app'] }}
apiAllowList={{ alertingAlertRuleFormSchema: ['grafana-authorised-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).not.toBeDefined();
expect(result.current.alertingAlertRuleFormSchema).not.toBeDefined();
});
it('should not share APIs with anyone if both the allowList and the blockList are empty', () => {
@@ -144,13 +143,16 @@ describe('RestrictedGrafanaApis', () => {
result = renderHook(() => useRestrictedGrafanaApis(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<RestrictedGrafanaApisContextProvider apis={apis} pluginId={'grafana-test-app'} apiAllowList={{ addPanel: [] }}>
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiAllowList={{ alertingAlertRuleFormSchema: [] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.result.current.addPanel).not.toBeDefined();
expect(result.result.current.alertingAlertRuleFormSchema).not.toBeDefined();
result = renderHook(() => useRestrictedGrafanaApis(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
@@ -159,8 +161,7 @@ describe('RestrictedGrafanaApis', () => {
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.result.current.addPanel).not.toBeDefined();
expect(result.result.current.alertingAlertRuleFormSchema).not.toBeDefined();
});
it('should not share APIs with blocked plugins', () => {
@@ -169,14 +170,13 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'grafana-test-app'}
apiBlockList={{ addPanel: ['grafana-test-app'] }}
apiBlockList={{ alertingAlertRuleFormSchema: ['grafana-test-app'] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).not.toBeDefined();
expect(result.current.alertingAlertRuleFormSchema).not.toBeDefined();
});
it('should not share APIs with plugins that match any block list regexes', () => {
@@ -185,13 +185,12 @@ describe('RestrictedGrafanaApis', () => {
<RestrictedGrafanaApisContextProvider
apis={apis}
pluginId={'myorg-test-app'}
apiBlockList={{ addPanel: [/^myorg-/] }}
apiBlockList={{ alertingAlertRuleFormSchema: [/^myorg-/] }}
>
{children}
</RestrictedGrafanaApisContextProvider>
),
});
// @ts-expect-error No APIs are defined yet
expect(result.current.addPanel).not.toBeDefined();
expect(result.current.alertingAlertRuleFormSchema).not.toBeDefined();
});
});
@@ -1,9 +1,16 @@
import { createContext, ReactElement, PropsWithChildren, useMemo, useContext } from 'react';
// Generic schema type to avoid zod dependency in @grafana/data
interface ZodSchema {
parse: (data: unknown) => unknown;
safeParse: (data: unknown) => { success: boolean; data?: unknown; error?: unknown };
}
export interface RestrictedGrafanaApisContextTypeInternal {
// Add types for restricted Grafana APIs here
// (Make sure that they are typed as optional properties)
// e.g. addPanel?: (vizPanel: VizPanel) => void;
alertingAlertRuleFormSchema?: ZodSchema;
}
// We are exposing this through a "type validation", to make sure that all APIs are optional (which helps plugins catering for scenarios when they are not available).
@@ -1,7 +1,8 @@
import { clamp } from 'lodash';
import { z } from 'zod';
import z from 'zod';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { alertingAlertRuleFormSchema } from 'app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { GrafanaAlertStateDecision, RulerRuleDTO } from 'app/types/unified-alerting-dto';
@@ -156,47 +157,11 @@ export function formValuesFromQueryParams(ruleDefinition: string, type: RuleForm
)
);
}
// schema for cloud rule form values. This is necessary because the cloud rule form values are not the same as the grafana rule form values.
// schema for grafana rule values is navigateToAlertFormSchema , shared in the restrictedGrafanaApis.
// TODO: add this to the DMA new plugin.
export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
// coerce prefill params to a valid RuleFormValues interface
const parsedRule = ruleFormValuesSchema.parse(rule);
return revealHiddenQueries({
...getDefaultFormValues(rule.type),
...parsedRule,
});
}
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
return revealHiddenQueries(rulerRuleToFormValues(rule));
}
export function defaultFormValuesForRuleType(ruleType: RuleFormType): RuleFormValues {
return {
...getDefaultFormValues(ruleType),
condition: 'C',
queries: getDefaultQueries(isGrafanaRecordingRuleByType(ruleType)),
type: ruleType,
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
};
}
// TODO This function is not 100% valid. There is no support for cloud form type because
// it's not valid from the path param point of view.
export function translateRouteParamToRuleType(param = ''): RuleFormType {
if (param === 'recording') {
return RuleFormType.cloudRecording;
}
if (param === 'grafana-recording') {
return RuleFormType.grafanaRecording;
}
return RuleFormType.grafana;
}
// we use this schema to coerce prefilled query params into a valid "FormValues" interface
const ruleFormValuesSchema = z.looseObject({
const cloudRuleFormValuesSchema = z.looseObject({
name: z.string().optional(),
type: z.enum(RuleFormType).catch(RuleFormType.grafana),
dataSourceName: z.string().optional().default(''),
@@ -273,3 +238,49 @@ const ruleFormValuesSchema = z.looseObject({
expression: z.string().optional(),
missingSeriesEvalsToResolve: z.number().optional(),
});
export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
let parsedRule: z.infer<typeof alertingAlertRuleFormSchema> | z.infer<typeof cloudRuleFormValuesSchema>;
// differencitate between cloud and grafana prefill
if (rule.type === RuleFormType.cloudAlerting) {
// we use this schema to coerce prefilled query params into a valid "FormValues" interface
parsedRule = cloudRuleFormValuesSchema.parse(rule);
} else {
// grafana prefill
// coerce prefill params to a valid RuleFormValues interface
parsedRule = alertingAlertRuleFormSchema.parse(rule);
}
return revealHiddenQueries({
...getDefaultFormValues(rule.type),
...parsedRule,
});
}
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
return revealHiddenQueries(rulerRuleToFormValues(rule));
}
export function defaultFormValuesForRuleType(ruleType: RuleFormType): RuleFormValues {
return {
...getDefaultFormValues(ruleType),
condition: 'C',
queries: getDefaultQueries(isGrafanaRecordingRuleByType(ruleType)),
type: ruleType,
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
};
}
// TODO This function is not 100% valid. There is no support for cloud form type because
// it's not valid from the path param point of view.
export function translateRouteParamToRuleType(param = ''): RuleFormType {
if (param === 'recording') {
return RuleFormType.cloudRecording;
}
if (param === 'grafana-recording') {
return RuleFormType.grafanaRecording;
}
return RuleFormType.grafana;
}
@@ -2,11 +2,13 @@ import { PropsWithChildren, ReactElement } from 'react';
import { RestrictedGrafanaApisContextProvider, RestrictedGrafanaApisContextType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { alertingAlertRuleFormSchemaApi } from 'app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema';
const restrictedGrafanaApis: RestrictedGrafanaApisContextType = config.featureToggles.restrictedPluginApis
? {
// Add your restricted APIs here
// (APIs that should be availble to ALL plugins should be shared via our packages, e.g. @grafana/data.)
alertingAlertRuleFormSchema: alertingAlertRuleFormSchemaApi.alertingAlertRuleFormSchema,
}
: {};
@@ -0,0 +1,50 @@
## Navigate to Alert Form Schema API
The `alertingAlertRuleFormSchema` API provides a Zod schema for navigating to the Grafana alert form with pre-filled data. This API is useful for plugins that need to validate data before navigating to the alert creation form.
### Available Schema
- `alertingAlertRuleFormSchema` - Schema for data used to navigate to the alert form with pre-filled values
### Usage Example
```ts
import { useRestrictedGrafanaApis } from "@grafana/data";
function MyAlertingPlugin() {
const { alertingAlertRuleFormSchema } = useRestrictedGrafanaApis();
const validateAndNavigateToAlertForm = (data: unknown) => {
if (!alertingAlertRuleFormSchema) {
console.warn('Navigate to alert form schema API not available');
return;
}
// Validate using the navigate to alert form schema
const result = alertingAlertRuleFormSchema.safeParse(data);
if (result.success) {
console.log('Valid navigation data:', result.data);
// Proceed with navigating to the alert form
} else {
console.error('Validation failed:', result.error.errors);
}
};
return (
<div>
<button onClick={() => validateAndNavigateToAlertForm(someNavigationData)}>Navigate to Alert Form</button>
</div>
);
}
```
### Configuration
To enable the navigate to alert form schema API for specific plugins, add the following to your Grafana configuration:
```ini
[plugins.restricted_apis_allowlist]
# Allow specific plugins to access the navigate to alert form schema API
alertingAlertRuleFormSchema = "myorg-alerting-plugin, grafana-enterprise-.*"
```
@@ -0,0 +1,203 @@
import { z } from 'zod';
import alertDef from 'app/features/alerting/state/alertDef';
import { RuleFormType } from 'app/features/alerting/unified/types/rule-form';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
// Schema for __expr__ type queries (reduce, threshold, etc.)
export const exprQuerySchema = z.object({
refId: z.string().describe('Reference ID for the query, e.g., "B", "C", etc.'),
type: z.enum(ExpressionQueryType).describe('Expression type'),
datasource: z.object({
uid: z.literal('__expr__').describe('Must be "__expr__" for expression queries'),
type: z.literal('__expr__').describe('Must be "__expr__" for expression queries'),
}),
conditions: z
.array(
z.object({
type: z.string().describe('Condition type, e.g., "query"'),
evaluator: z.object({
params: z.array(z.any()).describe('Parameters for the evaluator'),
type: z.enum(alertDef.evalFunctions.map((ef) => ef.value)).describe('Evaluator type'),
}),
operator: z.object({
type: z.enum(alertDef.evalOperators.map((eo) => eo.value)).describe('Operator type'),
}),
query: z.object({
params: z.array(z.string()).describe('Query parameters, typically the refId to evaluate'),
}),
reducer: z.object({
params: z.array(z.any()).describe('Parameters for the reducer'),
type: z.string().describe('Reducer type, e.g., "last", "avg", "sum", "count", "min", "max"'),
}),
})
)
.optional()
.describe('Conditions for the expression query'),
reducer: z.string().optional().describe('Reducer function, e.g., "last", "avg", "sum"'),
expression: z.string().optional().describe('Expression referencing other queries, e.g., "A"'),
math: z.string().optional().describe('Math expression for math type queries'),
});
// Schema for regular datasource queries
export const alertingQuerySchema = z.object({
refId: z.string().describe('Reference ID for the query, e.g., "A", "B", etc.'),
queryType: z.string().optional().default('alerting').describe('Type of query (e.g., "alerting", "recording")'),
expression: z
.string()
.optional()
.default('')
.describe('Query expression to be executed. This can not include variables (e.g. $var).'),
instant: z.boolean().optional().default(true).describe('Whether the query is an instant query'),
range: z
.boolean()
.optional()
.default(false)
.describe('Whether the query is a range query, should be false if instant is true'),
datasource: z.object({
type: z.string().optional().describe('Datasource type or "__expr__" when it is an expression query'),
uid: z.string().optional().describe('Datasource UID'),
}),
});
// Combined schema that supports both regular and expression queries
export const alertingModelSchema = z.union([alertingQuerySchema, exprQuerySchema]);
// Main navigate to alert form schema - merged from both alertingSchemaApi and formDefaults
export const alertingAlertRuleFormSchema = z.object({
// Common fields
name: z.string().optional().describe('Name of the alert rule'),
type: z.enum(RuleFormType).optional().catch(RuleFormType.grafana).describe('Type of the alert rule'),
dataSourceName: z.string().optional().default(''),
group: z.string().optional().describe('Alert group name'),
// Labels and annotations
labels: z
.array(
z.object({
key: z.string().describe('Label key'),
value: z.string().describe('Label value'),
})
)
.optional()
.default([])
.describe('Labels for the alert rule'),
annotations: z
.array(
z.object({
key: z
.string()
.describe('Annotation key; for dashboard panel annotations, use "__dashboardUid__" or "__panelId__"'),
value: z.string().describe('Annotation value'),
})
)
.optional().describe(`Optional annotations for the alert rule. When creating alerts from a dashboard panel, include:
- {"key": "__dashboardUid__", "value": "<dashboard-uid>"}
- {"key": "__panelId__", "value": "<panel-id>"}
These annotations link the alert back to the source dashboard and panel.`),
// Folder configuration
folder: z
.object({
kind: z.enum(['folder']).default('folder'),
uid: z.string().describe('Folder UID where the alert rule will be created'),
title: z.string().optional().default('').describe('Folder title'),
})
.optional()
.describe('Folder configuration for the alert rule'),
// Queries
queries: z
.array(
z.object({
refId: z.string().describe('Reference ID for the query (e.g., "A", "B", "C")'),
queryType: z.string().optional().default('instant').describe('Type of query (e.g., "instant")'),
relativeTimeRange: z
.object({
from: z.number().describe('Relative time from in seconds (e.g., 3600 for 1 hour)'),
to: z.number().default(0).describe('Relative time to in seconds (usually 0 for "now")'),
})
.optional(),
datasourceUid: z.string().describe('Datasource UID for the query'),
model: alertingModelSchema.describe('Query model containing the actual query configuration'),
})
)
.optional()
.default([])
.describe('Array of queries that form the alert rule'),
// Alert rule configuration
condition: z.string().optional().describe('Reference ID of the query that acts as the condition (e.g., "C")'),
noDataState: z.enum(GrafanaAlertStateDecision).optional().describe('State when no data is available'),
execErrState: z.enum(GrafanaAlertStateDecision).optional().describe('State when there is an execution error'),
evaluateEvery: z.string().optional().describe('Evaluation interval'),
evaluateFor: z.string().optional().describe('Evaluation duration'),
keepFiringFor: z.string().optional().describe('Keep firing duration'),
isPaused: z.boolean().optional().default(false).describe('Whether the rule is paused'),
// Manual routing and contact points
manualRouting: z
.boolean()
.optional()
.default(true)
.describe('Whether to use manual routing. If true, contactPoints are used.'),
contactPoints: z
.record(
z.string(),
z.object({
selectedContactPoint: z.string().describe('Selected contact point to send the alert to'),
overrideGrouping: z.boolean().describe('Whether to override the default grouping'),
groupBy: z.array(z.string()).describe('Group by labels'),
overrideTimings: z.boolean().describe('Whether to override the default timings'),
groupWaitValue: z.string().describe('Group wait value'),
groupIntervalValue: z.string().describe('Group interval value'),
repeatIntervalValue: z.string().describe('Repeat interval value'),
muteTimeIntervals: z.array(z.string()).describe('Mute time intervals'),
activeTimeIntervals: z.array(z.string()).describe('Active time intervals'),
})
)
.optional()
.default({
GRAFANA_RULES_SOURCE_NAME: {
selectedContactPoint: 'default',
overrideGrouping: false,
groupBy: [],
overrideTimings: false,
groupWaitValue: '',
groupIntervalValue: '',
repeatIntervalValue: '',
muteTimeIntervals: [],
activeTimeIntervals: [],
},
})
.describe('Contact points configuration'),
// Editor settings
editorSettings: z
.object({
simplifiedQueryEditor: z.boolean(),
simplifiedNotificationEditor: z.boolean(),
})
.optional()
.default({ simplifiedQueryEditor: true, simplifiedNotificationEditor: true })
.describe('Editor settings'),
// Additional fields
metric: z.string().optional().describe('Metric name for Grafana recording rules'),
targetDatasourceUid: z.string().optional().describe('Target datasource UID for Grafana recording rules'),
// Navigation
returnTo: z.string().optional().describe('Optional URL to return to after creating the alert'),
});
// Export types for use in plugins
export type AlertingAlertRuleFormSchemaType = z.infer<typeof alertingAlertRuleFormSchema>;
export type AlertingQuerySchemaType = z.infer<typeof alertingQuerySchema>;
export type ExprQuerySchemaType = z.infer<typeof exprQuerySchema>;
export type AlertingModelSchemaType = z.infer<typeof alertingModelSchema>;
// Simple API that only exposes the navigate to alert rule form schema
export const alertingAlertRuleFormSchemaApi = {
alertingAlertRuleFormSchema,
};