diff --git a/ui/src/components/MustacheEditor/alert-variables.test.ts b/ui/src/components/MustacheEditor/alert-variables.test.ts new file mode 100644 index 00000000..fb931125 --- /dev/null +++ b/ui/src/components/MustacheEditor/alert-variables.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + availableVariables, + extractReferences, + unknownReferences, +} from './alert-variables'; + +describe('availableVariables', () => { + it('returns only always-available vars when kind is undefined', () => { + const vars = availableVariables(undefined); + expect(vars.find((v) => v.path === 'env.slug')).toBeTruthy(); + expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined(); + expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined(); + }); + + it('adds exchange.* for EXCHANGE_MATCH kind', () => { + const vars = availableVariables('EXCHANGE_MATCH'); + expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy(); + expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined(); + }); + + it('adds log.* for LOG_PATTERN kind', () => { + const vars = availableVariables('LOG_PATTERN'); + expect(vars.find((v) => v.path === 'log.message')).toBeTruthy(); + }); + + it('reduces to env-only when reducedContext=true (connection URL editor)', () => { + const vars = availableVariables('ROUTE_METRIC', { reducedContext: true }); + expect(vars.every((v) => v.path.startsWith('env.'))).toBe(true); + }); +}); + +describe('extractReferences', () => { + it('finds bare variable refs', () => { + expect(extractReferences('Hello {{user.name}}, ack: {{alert.ackedBy}}')).toEqual([ + 'user.name', + 'alert.ackedBy', + ]); + }); + it('ignores section/comment tags', () => { + expect( + extractReferences('{{#items}}{{name}}{{/items}} {{!comment}}'), + ).toEqual(['name']); + }); + it('tolerates whitespace', () => { + expect(extractReferences('{{ alert.firedAt }}')).toEqual(['alert.firedAt']); + }); +}); + +describe('unknownReferences', () => { + it('flags references not in the allowed set', () => { + const allowed = availableVariables('ROUTE_METRIC'); + expect(unknownReferences('{{alert.id}} {{exchange.id}}', allowed)).toEqual(['exchange.id']); + }); +}); diff --git a/ui/src/components/MustacheEditor/alert-variables.ts b/ui/src/components/MustacheEditor/alert-variables.ts new file mode 100644 index 00000000..65a9dc76 --- /dev/null +++ b/ui/src/components/MustacheEditor/alert-variables.ts @@ -0,0 +1,109 @@ +import type { ConditionKind } from '../../api/queries/alertRules'; + +export type VariableType = + | 'string' + | 'Instant' + | 'number' + | 'boolean' + | 'url' + | 'uuid'; + +export interface AlertVariable { + path: string; // e.g. "alert.firedAt" + type: VariableType; + description: string; + sampleValue: string; // rendered as a faint suggestion preview + availableForKinds: 'always' | ConditionKind[]; + mayBeNull?: boolean; // show "may be null" badge in UI +} + +/** Variables the spec §8 context map exposes. Add to this registry whenever + * NotificationContextBuilder (backend) gains a new leaf. */ +export const ALERT_VARIABLES: AlertVariable[] = [ + // Always available + { path: 'env.slug', type: 'string', description: 'Environment slug', sampleValue: 'prod', availableForKinds: 'always' }, + { path: 'env.id', type: 'uuid', description: 'Environment UUID', sampleValue: '00000000-0000-0000-0000-000000000001', availableForKinds: 'always' }, + { path: 'rule.id', type: 'uuid', description: 'Rule UUID', sampleValue: '11111111-...', availableForKinds: 'always' }, + { path: 'rule.name', type: 'string', description: 'Rule display name', sampleValue: 'Order API error rate', availableForKinds: 'always' }, + { path: 'rule.severity', type: 'string', description: 'Rule severity', sampleValue: 'CRITICAL', availableForKinds: 'always' }, + { path: 'rule.description', type: 'string', description: 'Rule description', sampleValue: 'Paging ops if error rate >5%', availableForKinds: 'always' }, + { path: 'alert.id', type: 'uuid', description: 'Alert instance UUID', sampleValue: '22222222-...', availableForKinds: 'always' }, + { path: 'alert.state', type: 'string', description: 'Alert state', sampleValue: 'FIRING', availableForKinds: 'always' }, + { path: 'alert.firedAt', type: 'Instant', description: 'When the alert fired', sampleValue: '2026-04-20T14:33:10Z', availableForKinds: 'always' }, + { path: 'alert.resolvedAt', type: 'Instant', description: 'When the alert resolved', sampleValue: '2026-04-20T14:45:00Z', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.ackedBy', type: 'string', description: 'User who ack\'d the alert', sampleValue: 'alice', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.link', type: 'url', description: 'UI link to this alert', sampleValue: 'https://cameleer.example.com/alerts/inbox/2222...', availableForKinds: 'always' }, + { path: 'alert.currentValue', type: 'number', description: 'Observed metric value', sampleValue: '0.12', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.threshold', type: 'number', description: 'Rule threshold', sampleValue: '0.05', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.comparator', type: 'string', description: 'Rule comparator', sampleValue: 'GT', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.window', type: 'string', description: 'Rule window (human)', sampleValue: '5m', availableForKinds: 'always', mayBeNull: true }, + + // Scope-ish — still always available when scoped, but "may be null" if env-wide + { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', availableForKinds: 'always', mayBeNull: true }, + { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', availableForKinds: 'always', mayBeNull: true }, + { path: 'app.displayName', type: 'string', description: 'App display name', sampleValue: 'Order API', availableForKinds: 'always', mayBeNull: true }, + + // ROUTE_METRIC + { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] }, + + // EXCHANGE_MATCH + { path: 'exchange.id', type: 'string', description: 'Exchange ID', sampleValue: 'exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] }, + { path: 'exchange.status', type: 'string', description: 'Exchange status', sampleValue: 'FAILED', availableForKinds: ['EXCHANGE_MATCH'] }, + { path: 'exchange.link', type: 'url', description: 'UI link to exchange', sampleValue: '/exchanges/orders/route-1/exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] }, + + // AGENT_STATE + { path: 'agent.id', type: 'string', description: 'Agent instance ID', sampleValue: 'prod-orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] }, + { path: 'agent.name', type: 'string', description: 'Agent display name', sampleValue: 'orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] }, + { path: 'agent.state', type: 'string', description: 'Agent state', sampleValue: 'DEAD', availableForKinds: ['AGENT_STATE'] }, + + // DEPLOYMENT_STATE + { path: 'deployment.id', type: 'uuid', description: 'Deployment UUID', sampleValue: '44444444-...', availableForKinds: ['DEPLOYMENT_STATE'] }, + { path: 'deployment.status', type: 'string', description: 'Deployment status', sampleValue: 'FAILED', availableForKinds: ['DEPLOYMENT_STATE'] }, + + // LOG_PATTERN + { path: 'log.logger', type: 'string', description: 'Logger name', sampleValue: 'com.acme.Api', availableForKinds: ['LOG_PATTERN'] }, + { path: 'log.level', type: 'string', description: 'Log level', sampleValue: 'ERROR', availableForKinds: ['LOG_PATTERN'] }, + { path: 'log.message', type: 'string', description: 'Log message', sampleValue: 'TimeoutException...', availableForKinds: ['LOG_PATTERN'] }, + + // JVM_METRIC + { path: 'metric.name', type: 'string', description: 'Metric name', sampleValue: 'heap_used_percent', availableForKinds: ['JVM_METRIC'] }, + { path: 'metric.value', type: 'number', description: 'Metric value', sampleValue: '92.1', availableForKinds: ['JVM_METRIC'] }, +]; + +/** Filter variables to those available for the given condition kind. + * If kind is undefined (e.g. connection URL editor), returns only "always" vars + app.*. */ +export function availableVariables( + kind: ConditionKind | undefined, + opts: { reducedContext?: boolean } = {}, +): AlertVariable[] { + if (opts.reducedContext) { + return ALERT_VARIABLES.filter((v) => v.path.startsWith('env.')); + } + if (!kind) { + return ALERT_VARIABLES.filter( + (v) => v.availableForKinds === 'always', + ); + } + return ALERT_VARIABLES.filter( + (v) => v.availableForKinds === 'always' || v.availableForKinds.includes(kind), + ); +} + +/** Parse a Mustache template and return the set of `{{path}}` references it contains. + * Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}` — plain variable refs only. */ +export function extractReferences(template: string): string[] { + const out: string[] = []; + const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m; + while ((m = re.exec(template)) !== null) out.push(m[1]); + return out; +} + +/** Find references in a template that are not in the allowed-variable set. */ +export function unknownReferences( + template: string, + allowed: readonly AlertVariable[], +): string[] { + const allowedSet = new Set(allowed.map((v) => v.path)); + return extractReferences(template).filter((r) => !allowedSet.has(r)); +}