Files
cameleer-server/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
hsiegeln e0496fdba2 ui(alerts): ReviewStep — render-preview pane for existing rules
Wire up the existing POST /alerts/rules/{id}/render-preview endpoint
so rule authors can preview their Mustache-templated notification
before saving. Available in edit mode only (new rules require save
first — endpoint is id-bound). Matches the njams gap: their rules
builder ships no in-builder preview and operators compensate with
trial-and-error save/retry.

Implementation notes:
- ReviewStep gains an optional `ruleId` prop; when present, a
  "Preview notification" button calls `useRenderPreview` (the
  existing TanStack mutation in api/queries/alertRules.ts) and
  renders title + message in a titled, read-only pane styled like
  a notification card.
- Errors surface as a DS Alert (variant=error) beneath the button.
- `RuleEditorWizard` passes `ruleId={id}` through — mirrors the
  existing TriggerStep / NotifyStep wiring.
- No stateless (/render-preview without id) variant exists on the
  backend, so for new rules the button is simply omitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:58:17 +02:00

166 lines
5.4 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Alert, Button, Toggle } from '@cameleer/design-system';
import { useRenderPreview } from '../../../api/queries/alertRules';
import { describeApiError } from '../../../api/errors';
import { toRequest, type FormState } from './form-state';
/**
* Pure helper: returns a human-readable reason why saving should be blocked,
* or null when the rule is safe to save (from the wizard's perspective).
*
* Currently covers: a rule with no webhooks AND no targets would be rejected
* at the server edge (Task 3.3 validator) and would never notify anyone, so
* the wizard blocks it earlier with a clear reason. The helper is exported
* so `RuleEditorWizard` can also drive the Save button's `disabled` state
* off the same single source of truth.
*/
export function computeSaveBlockReason(form: FormState): string | null {
const noWebhooks = (form.webhooks ?? []).length === 0;
const noTargets = (form.targets ?? []).length === 0;
if (noWebhooks && noTargets) {
return 'Add at least one webhook or target \u2014 a rule with no recipients never notifies anyone.';
}
return null;
}
export function ReviewStep({
form,
setForm,
ruleId,
}: {
form: FormState;
setForm?: (f: FormState) => void;
/**
* Present only in edit mode. When absent the notification-preview button is
* hidden, because the backend `/render-preview` endpoint is id-bound and
* has no stateless variant — rendering against an unsaved draft would
* require a new endpoint and is explicitly out of scope here.
*/
ruleId?: string;
}) {
const req = toRequest(form);
const saveBlockReason = useMemo(() => computeSaveBlockReason(form), [form]);
const previewMutation = useRenderPreview();
const [preview, setPreview] = useState<{ title: string; message: string } | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const onPreview = async () => {
setPreviewError(null);
try {
const res = await previewMutation.mutateAsync({ id: ruleId!, req: {} });
setPreview({ title: res.title ?? '', message: res.message ?? '' });
} catch (e) {
setPreview(null);
setPreviewError(describeApiError(e));
}
};
return (
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
{saveBlockReason && (
<Alert variant="error" title="Rule cannot be saved yet">
{saveBlockReason}
</Alert>
)}
<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 &middot; for {form.forDurationSeconds}s &middot; 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>
)}
{ruleId && (
<div style={{ display: 'grid', gap: 8 }}>
<div>
<Button
variant="secondary"
onClick={onPreview}
disabled={previewMutation.isPending}
>
{previewMutation.isPending ? 'Rendering\u2026' : 'Preview notification'}
</Button>
</div>
{previewError && (
<Alert variant="error" title="Preview failed">
{previewError}
</Alert>
)}
{preview && (
<div
aria-label="Rendered notification preview"
style={{
border: '1px solid var(--border)',
borderRadius: 8,
padding: 12,
background: 'var(--surface-raised, var(--surface))',
display: 'grid',
gap: 6,
}}
>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Rendered notification preview
</div>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{preview.title || <em style={{ color: 'var(--text-muted)' }}>(empty title)</em>}
</div>
<pre
style={{
margin: 0,
fontSize: 12,
fontFamily: 'inherit',
whiteSpace: 'pre-wrap',
color: 'var(--text)',
}}
>
{preview.message || '(empty message)'}
</pre>
</div>
)}
</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>
);
}