Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { z } from '@kbn/zod';
import { validateDuration, validateEsqlQuery } from './validation';

/** Primitives */

const durationSchema = z.string().superRefine((value, ctx) => {
const error = validateDuration(value);
if (error) {
Expand All @@ -26,67 +28,193 @@ const esqlQuerySchema = z
}
});

const scheduleSchema = z
.object({
custom: durationSchema.describe('Rule execution interval (e.g. 1m, 5m).'),
})
.strict()
.describe('Schedule configuration for the rule.');
/** Kind */

export const ruleKindSchema = z.enum(['alert', 'signal']).describe('The kind of rule.');

export type RuleKind = z.infer<typeof ruleKindSchema>;

export const createRuleDataSchema = z
/** Metadata (required) */

const metadataSchema = z
.object({
name: z.string().min(1).max(64).describe('Human-readable rule name.'),
kind: ruleKindSchema,
tags: z
.array(z.string().max(64).describe('Rule tag.'))
.max(100)
.default([])
.describe('Tags attached to the rule.'),
schedule: scheduleSchema,
enabled: z.boolean().default(true).describe('Whether the rule is enabled.'),
query: esqlQuerySchema.describe('ES|QL query text to execute.'),
timeField: z
name: z.string().min(1).max(256).describe('Unique rule name/identifier.'),
owner: z.string().max(256).optional().describe('Owner of the rule.'),
labels: z.array(z.string().max(64)).max(100).optional().describe('Labels for categorization.'),
time_field: z
.string()
.min(1)
.max(128)
.default('@timestamp')
.describe('Time field to apply the lookback window to.'),
lookbackWindow: durationSchema.describe('Lookback window for the query (e.g. 5m, 1h).'),
groupingKey: z
.array(z.string())
.max(16)
.default([])
.describe('Fields to group alert events by.'),
.describe('Time field used for the lookback window range filter.'),
})
.strip();
.strict()
.describe('Rule metadata.');

export type CreateRuleData = z.infer<typeof createRuleDataSchema>;
/** Schedule (required) */

export const updateRuleDataSchema = z
const scheduleSchema = z
.object({
name: z.string().min(1).max(64).optional().describe('Human-readable rule name.'),
tags: z.array(z.string().max(64)).max(100).optional().describe('Tags attached to the rule.'),
schedule: scheduleSchema.optional(),
enabled: z.boolean().optional().describe('Whether the rule is enabled.'),
query: esqlQuerySchema.optional().describe('ES|QL query text to execute.'),
timeField: z
.string()
.min(1)
.max(128)
every: durationSchema.describe('Execution interval, e.g. 1m, 5m.'),
lookback: durationSchema
.optional()
.describe('Lookback window for the query (can also be expressed in ES|QL).'),
})
.strict()
.describe('Execution schedule configuration.');

/** Evaluation (required) */

const evaluationQuerySchema = z
.object({
base: esqlQuerySchema.describe('Base ES|QL query.'),
trigger: z
.object({
condition: z.string().min(1).max(5000).describe('Trigger condition (WHERE clause).'),
})
.strict()
.describe('Trigger condition.'),
})
.strict();

const evaluationSchema = z
.object({
query: evaluationQuerySchema,
})
.strict()
.describe('Detection query configuration.');

/** Recovery policy (optional) */

const recoveryPolicySchema = z
.object({
type: z.enum(['query', 'no_breach']).describe('Recovery detection type.'),
query: z
.object({
base: esqlQuerySchema
.optional()
.describe('Base ES|QL query for recovery (or reference to evaluation.query.base).'),
condition: z.string().max(5000).optional().describe('Recovery condition (WHERE clause).'),
})
.strict()
.optional()
.describe('Recovery query when type is query.'),
})
.strict()
.describe('Recovery detection configuration.');

/** State transition (optional, alert-only) */

const stateTransitionOperatorSchema = z.enum(['AND', 'OR']);

const stateTransitionSchema = z
.object({
pending_operator: stateTransitionOperatorSchema
.optional()
.describe('How to combine count and timeframe for pending.'),
pending_count: z
.number()
.int()
.min(0)
.optional()
.describe('Consecutive breaches before active.'),
pending_timeframe: durationSchema.optional().describe('Time window for pending evaluation.'),
recovering_operator: stateTransitionOperatorSchema
.optional()
.describe('Time field to apply the lookback window to.'),
lookbackWindow: durationSchema
.describe('How to combine count and timeframe for recovering.'),
recovering_count: z
.number()
.int()
.min(0)
.optional()
.describe('Lookback window for the query (e.g. 5m, 1h).'),
groupingKey: z
.array(z.string())
.describe('Consecutive recoveries before inactive.'),
recovering_timeframe: durationSchema
.optional()
.describe('Time window for recovering evaluation.'),
})
.strict()
.describe('Episode state transition thresholds (alert-only).');

/** Grouping (optional) */

const groupingSchema = z
.object({
fields: z
.array(z.string().max(256))
.max(16)
.describe('Fields to group by (convention: use ES|QL GROUP BY fields).'),
})
.strict()
.describe('Grouping configuration.');

/** No data (optional) */

const noDataSchema = z
.object({
behavior: z
.enum(['no_data', 'last_status', 'recover'])
.optional()
.describe('Fields to group alert events by.'),
.describe('Behavior when no data is detected.'),
timeframe: durationSchema.optional().describe('Time window after which no data is detected.'),
})
.strict()
.describe('No data handling configuration.');

/** Notification policies (optional) */

const notificationPolicyRefSchema = z
.object({
ref: z.string().min(1).describe('Reference to notification policy.'),
})
.strict();

/** Create rule API schema */

export const createRuleDataSchema = z
.object({
kind: ruleKindSchema,
metadata: metadataSchema,
schedule: scheduleSchema,
evaluation: evaluationSchema,
recovery_policy: recoveryPolicySchema.optional(),
state_transition: stateTransitionSchema.optional(),
grouping: groupingSchema.optional(),
no_data: noDataSchema.optional(),
notification_policies: z.array(notificationPolicyRefSchema).optional(),
})
.strip();

export type CreateRuleData = z.infer<typeof createRuleDataSchema>;

/** Update rule API schema — all fields optional for partial updates */

export const updateRuleDataSchema = z
.object({
kind: ruleKindSchema.optional(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided with @tiamliu and @joana-cps to not let users update the kind in the first phase because of the complexities with how to handle active alerts when changing the kind from alert to signal.

metadata: metadataSchema.partial().optional(),
schedule: scheduleSchema.partial().optional(),
evaluation: z
.object({
query: z
.object({
base: esqlQuerySchema.optional(),
trigger: z
.object({
condition: z.string().min(1).max(5000).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
recovery_policy: recoveryPolicySchema.optional().nullable(),
state_transition: stateTransitionSchema.optional().nullable(),
grouping: groupingSchema.optional().nullable(),
no_data: noDataSchema.optional().nullable(),
notification_policies: z.array(notificationPolicyRefSchema).optional().nullable(),
})
.strip();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,37 @@ import { createRuleDataSchema, type CreateRuleData } from '@kbn/alerting-v2-sche
import { YamlRuleEditor } from '@kbn/yaml-rule-editor';
import { RulesApi } from '../services/rules_api';

const DEFAULT_RULE_YAML = `name: Example rule
kind: alert
tags: []
const DEFAULT_RULE_YAML = `kind: alert

metadata:
name: Example rule
time_field: "@timestamp"

schedule:
custom: 1m
enabled: true
query: FROM logs-* | LIMIT 1
timeField: "@timestamp"
lookbackWindow: 5m
groupingKey: []`;
every: 1m
lookback: 5m

evaluation:
query:
base: |
FROM logs-*
| LIMIT 1
trigger:
condition: "WHERE true"`;

const DEFAULT_RULE_VALUES: CreateRuleData = {
name: 'Example rule',
kind: 'alert',
tags: [],
schedule: { custom: '1m' },
enabled: true,
query: 'FROM logs-* | LIMIT 1',
timeField: '@timestamp',
lookbackWindow: '5m',
groupingKey: [],
metadata: {
name: 'Example rule',
time_field: '@timestamp',
},
schedule: { every: '1m', lookback: '5m' },
evaluation: {
query: {
base: 'FROM logs-*\n| LIMIT 1',
trigger: { condition: 'WHERE true' },
},
},
};

const getErrorMessage = (error: unknown) => {
Expand Down Expand Up @@ -113,19 +123,34 @@ export const CreateRulePage = () => {
return;
}

// Map the API response back to the create schema shape for editing.
const nextPayload: CreateRuleData = {
...DEFAULT_RULE_VALUES,
name: rule.name,
kind: rule.kind ?? DEFAULT_RULE_VALUES.kind,
tags: rule.tags ?? DEFAULT_RULE_VALUES.tags,
schedule: rule.schedule?.custom
? { custom: rule.schedule.custom }
: DEFAULT_RULE_VALUES.schedule,
enabled: rule.enabled ?? DEFAULT_RULE_VALUES.enabled,
query: rule.query ?? DEFAULT_RULE_VALUES.query,
timeField: rule.timeField ?? DEFAULT_RULE_VALUES.timeField,
lookbackWindow: rule.lookbackWindow ?? DEFAULT_RULE_VALUES.lookbackWindow,
groupingKey: rule.groupingKey ?? DEFAULT_RULE_VALUES.groupingKey,
metadata: {
name: rule.metadata?.name ?? DEFAULT_RULE_VALUES.metadata.name,
owner: rule.metadata?.owner,
labels: rule.metadata?.labels,
time_field: rule.metadata?.time_field ?? DEFAULT_RULE_VALUES.metadata.time_field,
},
schedule: {
every: rule.schedule?.every ?? DEFAULT_RULE_VALUES.schedule.every,
lookback: rule.schedule?.lookback ?? DEFAULT_RULE_VALUES.schedule.lookback,
},
evaluation: {
query: {
base: rule.evaluation?.query?.base ?? DEFAULT_RULE_VALUES.evaluation.query.base,
trigger: {
condition:
rule.evaluation?.query?.trigger?.condition ??
DEFAULT_RULE_VALUES.evaluation.query.trigger.condition,
},
},
},
recovery_policy: rule.recovery_policy,
state_transition: rule.state_transition,
grouping: rule.grouping,
no_data: rule.no_data,
notification_policies: rule.notification_policies,
};

setYaml(dump(nextPayload, { lineWidth: 120, noRefs: true }));
Expand Down
Loading