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() {
|
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