feat(ui/alerts): ReviewStep + promotion prefill warnings
Review step dumps a human summary plus raw request JSON, and (when a
setter is supplied) offers an Enabled-on-save Toggle. Promotion prefill
now returns {form, warnings}: clears agent IDs (per-env), flags missing
apps in target env, and flags webhook connections not allowed in target
env. 4 Vitest cases cover copy-name, agent clear, app-missing, and
webhook-not-allowed paths.
The wizard now consumes {form, warnings}; Task 25 renders the warnings
banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,62 @@
|
||||
import type { FormState } from './form-state';
|
||||
import { Toggle } from '@cameleer/design-system';
|
||||
import { toRequest, type FormState } from './form-state';
|
||||
|
||||
export function ReviewStep({
|
||||
form: _form,
|
||||
setForm: _setForm,
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm?: (f: FormState) => void;
|
||||
}) {
|
||||
return <div>Review step — TODO Task 24</div>;
|
||||
const req = toRequest(form);
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
|
||||
<div>
|
||||
<strong>Name:</strong> {form.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Severity:</strong> {form.severity}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Scope:</strong> {form.scopeKind}
|
||||
{form.scopeKind !== 'env' &&
|
||||
` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Condition kind:</strong> {form.conditionKind}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m
|
||||
</div>
|
||||
<div>
|
||||
<strong>Targets:</strong> {form.targets.length}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Webhooks:</strong> {form.webhooks.length}
|
||||
</div>
|
||||
{setForm && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||
label="Enabled on save"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Raw request JSON</summary>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: 'var(--code-bg)',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(req, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ export default function RuleEditorWizard() {
|
||||
return;
|
||||
}
|
||||
if (promoteFrom && sourceRuleQuery.data) {
|
||||
setForm(prefillFromPromotion(sourceRuleQuery.data));
|
||||
const { form: prefilled } = prefillFromPromotion(sourceRuleQuery.data);
|
||||
setForm(prefilled);
|
||||
return;
|
||||
}
|
||||
if (!isEdit && !promoteFrom) {
|
||||
|
||||
74
ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
Normal file
74
ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { prefillFromPromotion } from './promotion-prefill';
|
||||
import type { AlertRuleResponse } from '../../../api/queries/alertRules';
|
||||
|
||||
function fakeRule(overrides: Partial<AlertRuleResponse> = {}): AlertRuleResponse {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
environmentId: '22222222-2222-2222-2222-222222222222',
|
||||
name: 'High error rate',
|
||||
description: undefined,
|
||||
severity: 'CRITICAL',
|
||||
enabled: true,
|
||||
conditionKind: 'ROUTE_METRIC',
|
||||
condition: {
|
||||
kind: 'RouteMetricCondition',
|
||||
scope: { appSlug: 'orders' },
|
||||
} as unknown as AlertRuleResponse['condition'],
|
||||
evaluationIntervalSeconds: 60,
|
||||
forDurationSeconds: 0,
|
||||
reNotifyMinutes: 60,
|
||||
notificationTitleTmpl: '{{rule.name}}',
|
||||
notificationMessageTmpl: 'msg',
|
||||
webhooks: [],
|
||||
targets: [],
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
createdBy: 'alice',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
updatedBy: 'alice',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('prefillFromPromotion', () => {
|
||||
it('appends "(copy)" to name', () => {
|
||||
const { form } = prefillFromPromotion(fakeRule());
|
||||
expect(form.name).toBe('High error rate (copy)');
|
||||
});
|
||||
|
||||
it('warns + clears agentId when source rule is agent-scoped', () => {
|
||||
const { form, warnings } = prefillFromPromotion(
|
||||
fakeRule({
|
||||
conditionKind: 'AGENT_STATE',
|
||||
condition: {
|
||||
kind: 'AgentStateCondition',
|
||||
scope: { appSlug: 'orders', agentId: 'orders-0' },
|
||||
state: 'DEAD',
|
||||
forSeconds: 60,
|
||||
} as unknown as AlertRuleResponse['condition'],
|
||||
}),
|
||||
);
|
||||
expect(form.agentId).toBe('');
|
||||
expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('warns if app does not exist in target env', () => {
|
||||
const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] });
|
||||
expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('warns if webhook connection is not allowed in target env', () => {
|
||||
const rule = fakeRule({
|
||||
webhooks: [
|
||||
{
|
||||
id: 'w1',
|
||||
outboundConnectionId: 'conn-prod',
|
||||
bodyOverride: undefined,
|
||||
headerOverrides: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
|
||||
expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,59 @@
|
||||
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;
|
||||
export interface PrefillWarning {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PrefillOptions {
|
||||
targetEnvAppSlugs?: string[];
|
||||
/** IDs of outbound connections allowed in the target env. */
|
||||
targetEnvAllowedConnectionIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side prefill when promoting a rule from another env. Emits warnings for
|
||||
* fields that cross env boundaries (agent IDs, apps missing in target env,
|
||||
* outbound connections not allowed in target env).
|
||||
*/
|
||||
export function prefillFromPromotion(
|
||||
source: AlertRuleResponse,
|
||||
opts: PrefillOptions = {},
|
||||
): { form: FormState; warnings: PrefillWarning[] } {
|
||||
const form = initialForm(source);
|
||||
form.name = `${source.name ?? 'rule'} (copy)`;
|
||||
const warnings: PrefillWarning[] = [];
|
||||
|
||||
// Agent IDs are per-env, can't transfer.
|
||||
if (form.agentId) {
|
||||
warnings.push({
|
||||
field: 'scope.agentId',
|
||||
message: `Agent \`${form.agentId}\` is specific to the source env \u2014 cleared for target env.`,
|
||||
});
|
||||
form.agentId = '';
|
||||
if (form.scopeKind === 'agent') form.scopeKind = 'app';
|
||||
}
|
||||
|
||||
// App slug: warn if not present in target env.
|
||||
if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) {
|
||||
warnings.push({
|
||||
field: 'scope.appSlug',
|
||||
message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Webhook connections: warn if connection is not allowed in target env.
|
||||
if (opts.targetEnvAllowedConnectionIds) {
|
||||
for (const w of form.webhooks) {
|
||||
if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) {
|
||||
warnings.push({
|
||||
field: `webhooks[${w.outboundConnectionId}]`,
|
||||
message: `Outbound connection is not allowed in the target env \u2014 remove or pick another before saving.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { form, warnings };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user