Files
cameleer-server/ui/src/components/MustacheEditor/alert-variables.ts

121 lines
6.7 KiB
TypeScript
Raw Normal View History

refactor(ui/alerts): address code-review findings on alerting-enums Follow-up to 83837ada addressing the critical-review feedback: - Duplicate ConditionKind type consolidated: the one in api/queries/alertRules.ts (which was nullable — wrong) is gone; single source of truth lives in this module. - Module moved out of api/ into pages/Alerts/ where it belongs. api/ is the data layer; labels + hide lists are view-layer concerns. - Hidden values formalised: Comparator.EQ and JvmAggregation.LATEST are intentionally not surfaced in dropdowns (noisy / wrong feature boundary, see in-file comments). They remain in the type unions so rules that carry those values save/load correctly — we just don't advertise them in the UI. - JvmAggregation declaration order restored to MAX/AVG/MIN (matches what users saw before 83837ada). LATEST declared last; hidden. - Snapshot tests for every visible *_OPTIONS array — reviewer signal in future PRs when a backend enum change or hide-list edit silently reshapes the dropdown. - `toOptions` gains a JSDoc noting that label-map declaration order is load-bearing (ES2015 Object.keys insertion-order guarantee). - **Honest about the springdoc schema quirk**: the generated polymorphic condition types resolve to `never` at the TypeScript level (two conflicting `kind` discriminators — the class-name literal and the Jackson enum — intersect to never), which silently defeated `Record<T, string>` exhaustiveness. The previous commit's "schema-derived enums" claim was accurate only for the flat-field enums (ConditionKind, Severity, TargetKind); condition-specific enums (RouteMetric, Comparator, JvmAggregation, ExchangeFireMode) were silently `never`. Those are now declared as hand-written string-literal unions with a top-of-file comment spelling out the issue and the regen-and-compare workflow. Real upstream fix is a backend-side adjustment to how springdoc emits polymorphic `@JsonSubTypes` — out of scope for this phase. Verified: ui build green, 56/56 vitest pass (49 pre-existing + 7 new enum snapshots). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:26:16 +02:00
import type { ConditionKind } from '../../pages/Alerts/enums';
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 },
// App subtree — populated on every kind except env-wide rules
{ path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders',
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true },
{ path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...',
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true },
// ROUTE_METRIC + EXCHANGE_MATCH share route.*
{ path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1',
availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },
{ path: 'route.uri', type: 'string', description: 'Route URI', sampleValue: 'direct:orders',
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'] },
// AGENT_STATE + JVM_METRIC share agent.id/name; AGENT_STATE adds 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 — leaf names match NotificationContextBuilder (log.pattern + log.matchCount)
{ path: 'log.pattern', type: 'string', description: 'Matched log pattern', sampleValue: 'TimeoutException',
availableForKinds: ['LOG_PATTERN'] },
{ path: 'log.matchCount', type: 'number', description: 'Matches in window', sampleValue: '7',
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));
}