diff --git a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx new file mode 100644 index 00000000..a22fe75a --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx @@ -0,0 +1,5 @@ +import type { FormState } from './form-state'; + +export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { + return
Condition step — TODO Task 21
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx new file mode 100644 index 00000000..59d4d853 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx @@ -0,0 +1,13 @@ +import type { FormState } from './form-state'; + +export function NotifyStep({ + form: _form, + setForm: _setForm, + ruleId: _ruleId, +}: { + form: FormState; + setForm: (f: FormState) => void; + ruleId?: string; +}) { + return
Notify step — TODO Task 23
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx new file mode 100644 index 00000000..6801e71a --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx @@ -0,0 +1,11 @@ +import type { FormState } from './form-state'; + +export function ReviewStep({ + form: _form, + setForm: _setForm, +}: { + form: FormState; + setForm?: (f: FormState) => void; +}) { + return
Review step — TODO Task 24
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx index 26c55fc7..84ab1605 100644 --- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -1,3 +1,154 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router'; +import { Button, SectionHeader, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../../components/PageLoader'; +import { + useAlertRule, + useCreateAlertRule, + useUpdateAlertRule, +} from '../../../api/queries/alertRules'; +import { + initialForm, + toRequest, + validateStep, + WIZARD_STEPS, + type FormState, + type WizardStep, +} from './form-state'; +import { ScopeStep } from './ScopeStep'; +import { ConditionStep } from './ConditionStep'; +import { TriggerStep } from './TriggerStep'; +import { NotifyStep } from './NotifyStep'; +import { ReviewStep } from './ReviewStep'; +import { prefillFromPromotion } from './promotion-prefill'; +import css from './wizard.module.css'; + +const STEP_LABELS: Record = { + scope: '1. Scope', + condition: '2. Condition', + trigger: '3. Trigger', + notify: '4. Notify', + review: '5. Review', +}; + export default function RuleEditorWizard() { - return
RuleEditorWizard — coming soon
; + const navigate = useNavigate(); + const { id } = useParams<{ id?: string }>(); + const [search] = useSearchParams(); + const { toast } = useToast(); + + const isEdit = !!id; + const existingQuery = useAlertRule(isEdit ? id : undefined); + + // Promotion prefill uses a separate query against the source env. In + // Plan 03 we reuse `useAlertRule` (current-env scoped); cross-env + // fetching is handled server-side in the promote query hook when wired. + const promoteFrom = search.get('promoteFrom') ?? undefined; + const promoteRuleId = search.get('ruleId') ?? undefined; + const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined); + + const [step, setStep] = useState('scope'); + const [form, setForm] = useState(null); + + // Initialize form once the existing or source rule loads. + useEffect(() => { + if (form) return; + if (isEdit && existingQuery.data) { + setForm(initialForm(existingQuery.data)); + return; + } + if (promoteFrom && sourceRuleQuery.data) { + setForm(prefillFromPromotion(sourceRuleQuery.data)); + return; + } + if (!isEdit && !promoteFrom) { + setForm(initialForm()); + } + }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]); + + const create = useCreateAlertRule(); + const update = useUpdateAlertRule(id ?? ''); + + if (!form) return ; + + const idx = WIZARD_STEPS.indexOf(step); + const errors = validateStep(step, form); + + const onNext = () => { + if (errors.length > 0) { + toast({ title: 'Fix validation errors before continuing', description: errors.join(' \u00b7 '), variant: 'error' }); + return; + } + if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]); + }; + const onBack = () => { + if (idx > 0) setStep(WIZARD_STEPS[idx - 1]); + }; + + const onSave = async () => { + try { + if (isEdit) { + await update.mutateAsync(toRequest(form)); + toast({ title: 'Rule updated', description: form.name, variant: 'success' }); + } else { + await create.mutateAsync(toRequest(form)); + toast({ title: 'Rule created', description: form.name, variant: 'success' }); + } + navigate('/alerts/rules'); + } catch (e) { + toast({ title: 'Save failed', description: String(e), variant: 'error' }); + } + }; + + const body = + step === 'scope' ? ( + + ) : step === 'condition' ? ( + + ) : step === 'trigger' ? ( + + ) : step === 'notify' ? ( + + ) : ( + + ); + + return ( +
+
+ {isEdit ? `Edit rule: ${form.name}` : 'New alert rule'} + {promoteFrom && ( +
+ Promoting from {promoteFrom} — review and adjust, then save. +
+ )} +
+ +
{body}
+
+ + {idx < WIZARD_STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+ ); } diff --git a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx new file mode 100644 index 00000000..649457f0 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx @@ -0,0 +1,5 @@ +import type { FormState } from './form-state'; + +export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { + return
Scope step — TODO Task 20
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx new file mode 100644 index 00000000..7ab844be --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx @@ -0,0 +1,13 @@ +import type { FormState } from './form-state'; + +export function TriggerStep({ + form: _form, + setForm: _setForm, + ruleId: _ruleId, +}: { + form: FormState; + setForm: (f: FormState) => void; + ruleId?: string; +}) { + return
Trigger step — TODO Task 22
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.test.ts b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts new file mode 100644 index 00000000..40e68df8 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { initialForm, toRequest, validateStep } from './form-state'; + +describe('initialForm', () => { + it('defaults to env-wide ROUTE_METRIC with safe intervals', () => { + const f = initialForm(); + expect(f.scopeKind).toBe('env'); + expect(f.conditionKind).toBe('ROUTE_METRIC'); + expect(f.evaluationIntervalSeconds).toBeGreaterThanOrEqual(5); + expect(f.enabled).toBe(true); + }); +}); + +describe('toRequest', () => { + it('strips empty scope fields for env-wide rules', () => { + const f = initialForm(); + f.name = 'test'; + const req = toRequest(f); + const scope = (req.condition as unknown as { scope: Record }).scope; + expect(scope.appSlug).toBeUndefined(); + expect(scope.routeId).toBeUndefined(); + expect(scope.agentId).toBeUndefined(); + }); + + it('includes appSlug for app/route/agent scopes', () => { + const f = initialForm(); + f.scopeKind = 'app'; + f.appSlug = 'orders'; + const req = toRequest(f); + const scope = (req.condition as unknown as { scope: Record }).scope; + expect(scope.appSlug).toBe('orders'); + }); +}); + +describe('validateStep', () => { + it('flags blank name on scope step', () => { + expect(validateStep('scope', initialForm())).toContain('Name is required.'); + }); + it('flags app requirement for app-scope', () => { + const f = initialForm(); + f.name = 'x'; + f.scopeKind = 'app'; + expect(validateStep('scope', f).some((e) => /App is required/.test(e))).toBe(true); + }); + it('flags intervals below floor on trigger step', () => { + const f = initialForm(); + f.evaluationIntervalSeconds = 1; + expect(validateStep('trigger', f).some((e) => /Evaluation interval/.test(e))).toBe(true); + }); +}); diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.ts b/ui/src/pages/Alerts/RuleEditor/form-state.ts new file mode 100644 index 00000000..e6cee874 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/form-state.ts @@ -0,0 +1,151 @@ +import type { + AlertRuleRequest, + AlertRuleResponse, + ConditionKind, + AlertCondition, +} from '../../../api/queries/alertRules'; + +export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review'; +export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review']; + +export interface FormState { + name: string; + description: string; + severity: 'CRITICAL' | 'WARNING' | 'INFO'; + enabled: boolean; + + // Scope (radio: env-wide | app | route | agent) + scopeKind: 'env' | 'app' | 'route' | 'agent'; + appSlug: string; + routeId: string; + agentId: string; + + conditionKind: ConditionKind; + condition: Partial; + + evaluationIntervalSeconds: number; + forDurationSeconds: number; + reNotifyMinutes: number; + + notificationTitleTmpl: string; + notificationMessageTmpl: string; + + webhooks: Array<{ + outboundConnectionId: string; + bodyOverride: string; + headerOverrides: Array<{ key: string; value: string }>; + }>; + + targets: Array<{ kind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>; +} + +export function initialForm(existing?: AlertRuleResponse): FormState { + if (!existing) { + return { + name: '', + description: '', + severity: 'WARNING', + enabled: true, + scopeKind: 'env', + appSlug: '', + routeId: '', + agentId: '', + conditionKind: 'ROUTE_METRIC', + condition: { kind: 'ROUTE_METRIC' } as Partial, + evaluationIntervalSeconds: 60, + forDurationSeconds: 0, + reNotifyMinutes: 60, + notificationTitleTmpl: '{{rule.name}} is firing', + notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}', + webhooks: [], + targets: [], + }; + } + const scope = ((existing.condition as { scope?: { appSlug?: string; routeId?: string; agentId?: string } } | undefined)?.scope) ?? {}; + const scopeKind: FormState['scopeKind'] = scope.agentId + ? 'agent' + : scope.routeId + ? 'route' + : scope.appSlug + ? 'app' + : 'env'; + return { + name: existing.name ?? '', + description: existing.description ?? '', + severity: (existing.severity ?? 'WARNING') as FormState['severity'], + enabled: existing.enabled ?? true, + scopeKind, + appSlug: scope.appSlug ?? '', + routeId: scope.routeId ?? '', + agentId: scope.agentId ?? '', + conditionKind: (existing.conditionKind ?? 'ROUTE_METRIC') as ConditionKind, + condition: (existing.condition ?? { kind: existing.conditionKind }) as Partial, + evaluationIntervalSeconds: existing.evaluationIntervalSeconds ?? 60, + forDurationSeconds: existing.forDurationSeconds ?? 0, + reNotifyMinutes: existing.reNotifyMinutes ?? 60, + notificationTitleTmpl: existing.notificationTitleTmpl ?? '{{rule.name}} is firing', + notificationMessageTmpl: existing.notificationMessageTmpl ?? 'Alert {{alert.id}} fired at {{alert.firedAt}}', + webhooks: (existing.webhooks ?? []).map((w) => ({ + outboundConnectionId: (w.outboundConnectionId ?? '') as string, + bodyOverride: w.bodyOverride ?? '', + headerOverrides: Object.entries((w.headerOverrides ?? {}) as Record) + .map(([key, value]) => ({ key, value })), + })), + targets: (existing.targets ?? []).map((t) => ({ + kind: (t.kind ?? 'USER') as 'USER' | 'GROUP' | 'ROLE', + targetId: t.targetId ?? '', + })), + }; +} + +export function toRequest(f: FormState): AlertRuleRequest { + const scope: Record = {}; + if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined; + if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined; + if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined; + + const condition = { ...(f.condition as Record), kind: f.conditionKind, scope } as unknown as AlertCondition; + + return { + name: f.name, + description: f.description || undefined, + severity: f.severity, + enabled: f.enabled, + conditionKind: f.conditionKind, + condition, + evaluationIntervalSeconds: f.evaluationIntervalSeconds, + forDurationSeconds: f.forDurationSeconds, + reNotifyMinutes: f.reNotifyMinutes, + notificationTitleTmpl: f.notificationTitleTmpl, + notificationMessageTmpl: f.notificationMessageTmpl, + webhooks: f.webhooks.map((w) => ({ + outboundConnectionId: w.outboundConnectionId, + bodyOverride: w.bodyOverride || undefined, + headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])), + })), + targets: f.targets.map((t) => ({ kind: t.kind, targetId: t.targetId })), + } as AlertRuleRequest; +} + +export function validateStep(step: WizardStep, f: FormState): string[] { + const errs: string[] = []; + if (step === 'scope') { + if (!f.name.trim()) errs.push('Name is required.'); + if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.'); + if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.'); + if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.'); + } + if (step === 'condition') { + if (!f.conditionKind) errs.push('Condition kind is required.'); + } + if (step === 'trigger') { + if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be \u2265 5 s.'); + if (f.forDurationSeconds < 0) errs.push('For-duration must be \u2265 0.'); + if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be \u2265 0.'); + } + if (step === 'notify') { + if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.'); + if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.'); + } + return errs; +} diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts new file mode 100644 index 00000000..4f3ec43d --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts @@ -0,0 +1,15 @@ +import { initialForm, type FormState } from './form-state'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +/** + * Prefill the wizard form from a source rule being promoted from another env. + * + * Task 19 scaffolding: reuses the edit-prefill path and renames the rule. + * Task 24 rewrites this to compute scope-adjustment warnings and return + * `{ form, warnings }`. + */ +export function prefillFromPromotion(source: AlertRuleResponse): FormState { + const f = initialForm(source); + f.name = `${source.name ?? 'rule'} (copy)`; + return f; +} diff --git a/ui/src/pages/Alerts/RuleEditor/wizard.module.css b/ui/src/pages/Alerts/RuleEditor/wizard.module.css new file mode 100644 index 00000000..4dfd103f --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/wizard.module.css @@ -0,0 +1,57 @@ +.wizard { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.promoteBanner { + padding: 8px 12px; + background: var(--amber-bg, rgba(255, 180, 0, 0.12)); + border: 1px solid var(--amber); + border-radius: 6px; + font-size: 13px; +} + +.steps { + display: flex; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} + +.step { + background: none; + border: none; + padding: 8px 12px; + border-bottom: 2px solid transparent; + cursor: pointer; + color: var(--muted); + font-size: 13px; +} + +.stepActive { + color: var(--fg); + border-bottom-color: var(--accent); +} + +.stepDone { + color: var(--fg); +} + +.stepBody { + min-height: 320px; +} + +.footer { + display: flex; + justify-content: space-between; +}