diff --git a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx index 5ba64517caf..3941bef6e6b 100644 --- a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx +++ b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx @@ -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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @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 }) => ( - + {children} ), }); - // @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', () => { ), }); - // @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', () => { {children} ), }); - // @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', () => { {children} ), }); - // @ts-expect-error No APIs are defined yet - expect(result.current.addPanel).not.toBeDefined(); + expect(result.current.alertingAlertRuleFormSchema).not.toBeDefined(); }); }); diff --git a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx index cfbc20eed4e..5dff77ccb03 100644 --- a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx +++ b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx @@ -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). diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.ts index 3071b6a75c6..064fd6c93e3 100644 --- a/public/app/features/alerting/unified/rule-editor/formDefaults.ts +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.ts @@ -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 { - // coerce prefill params to a valid RuleFormValues interface - const parsedRule = ruleFormValuesSchema.parse(rule); - - return revealHiddenQueries({ - ...getDefaultFormValues(rule.type), - ...parsedRule, - }); -} - -export function formValuesFromExistingRule(rule: RuleWithLocation) { - 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 { + let parsedRule: z.infer | z.infer; + // 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) { + 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; +} diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx b/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx index 3ecbd8f95c0..4b9caec649f 100644 --- a/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx +++ b/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx @@ -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, } : {}; diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/alerting/README.md b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/README.md new file mode 100644 index 00000000000..eab43d6cd8a --- /dev/null +++ b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/README.md @@ -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 ( +
+ +
+ ); +} +``` + +### 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-.*" +``` diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts new file mode 100644 index 00000000000..4470c195544 --- /dev/null +++ b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts @@ -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": ""} + - {"key": "__panelId__", "value": ""} + 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; +export type AlertingQuerySchemaType = z.infer; +export type ExprQuerySchemaType = z.infer; +export type AlertingModelSchemaType = z.infer; + +// Simple API that only exposes the navigate to alert rule form schema +export const alertingAlertRuleFormSchemaApi = { + alertingAlertRuleFormSchema, +};