feat(ui/alerts): rule editor wizard shell + form-state module
Wizard navigates 5 steps (scope/condition/trigger/notify/review) with
per-step validation. form-state module is the single source of truth for
the rule form; initialForm/toRequest/validateStep are unit-tested (6
tests). Step components are stubbed and will be implemented in Tasks
20-24. prefillFromPromotion is a thin wrapper in this commit; Task 24
rewrites it to compute scope-adjustment warnings.
Deviation notes:
- FormState.targets uses {kind, targetId} to match AlertRuleTarget DTO
field names (plan draft had targetKind).
- toRequest casts through Record<string, unknown> so the spread over
the Partial<AlertCondition> union typechecks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
Normal file
5
ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
return <div>Condition step — TODO Task 21</div>;
|
||||
}
|
||||
13
ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
Normal file
13
ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
Normal file
@@ -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 <div>Notify step — TODO Task 23</div>;
|
||||
}
|
||||
11
ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
Normal file
11
ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
export function ReviewStep({
|
||||
form: _form,
|
||||
setForm: _setForm,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm?: (f: FormState) => void;
|
||||
}) {
|
||||
return <div>Review step — TODO Task 24</div>;
|
||||
}
|
||||
@@ -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<WizardStep, string> = {
|
||||
scope: '1. Scope',
|
||||
condition: '2. Condition',
|
||||
trigger: '3. Trigger',
|
||||
notify: '4. Notify',
|
||||
review: '5. Review',
|
||||
};
|
||||
|
||||
export default function RuleEditorWizard() {
|
||||
return <div>RuleEditorWizard — coming soon</div>;
|
||||
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<WizardStep>('scope');
|
||||
const [form, setForm] = useState<FormState | null>(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 <PageLoader />;
|
||||
|
||||
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' ? (
|
||||
<ScopeStep form={form} setForm={setForm} />
|
||||
) : step === 'condition' ? (
|
||||
<ConditionStep form={form} setForm={setForm} />
|
||||
) : step === 'trigger' ? (
|
||||
<TriggerStep form={form} setForm={setForm} ruleId={id} />
|
||||
) : step === 'notify' ? (
|
||||
<NotifyStep form={form} setForm={setForm} ruleId={id} />
|
||||
) : (
|
||||
<ReviewStep form={form} setForm={setForm} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.wizard}>
|
||||
<div className={css.header}>
|
||||
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
|
||||
{promoteFrom && (
|
||||
<div className={css.promoteBanner}>
|
||||
Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<nav className={css.steps}>
|
||||
{WIZARD_STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
|
||||
onClick={() => setStep(s)}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={css.stepBody}>{body}</div>
|
||||
<div className={css.footer}>
|
||||
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{idx < WIZARD_STEPS.length - 1 ? (
|
||||
<Button variant="primary" onClick={onNext}>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
|
||||
{isEdit ? 'Save changes' : 'Create rule'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
Normal file
5
ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { FormState } from './form-state';
|
||||
|
||||
export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||||
return <div>Scope step — TODO Task 20</div>;
|
||||
}
|
||||
13
ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
Normal file
13
ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
Normal file
@@ -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 <div>Trigger step — TODO Task 22</div>;
|
||||
}
|
||||
50
ui/src/pages/Alerts/RuleEditor/form-state.test.ts
Normal file
50
ui/src/pages/Alerts/RuleEditor/form-state.test.ts
Normal file
@@ -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<string, string | undefined> }).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<string, string | undefined> }).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);
|
||||
});
|
||||
});
|
||||
151
ui/src/pages/Alerts/RuleEditor/form-state.ts
Normal file
151
ui/src/pages/Alerts/RuleEditor/form-state.ts
Normal file
@@ -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<AlertCondition>;
|
||||
|
||||
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<AlertCondition>,
|
||||
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<AlertCondition>,
|
||||
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<string, string>)
|
||||
.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<string, string | undefined> = {};
|
||||
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<string, unknown>), 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;
|
||||
}
|
||||
15
ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
Normal file
15
ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
Normal file
@@ -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;
|
||||
}
|
||||
57
ui/src/pages/Alerts/RuleEditor/wizard.module.css
Normal file
57
ui/src/pages/Alerts/RuleEditor/wizard.module.css
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user