Skip to content
Merged
168 changes: 119 additions & 49 deletions apps/web/src/components/alerts/alert-create-form-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,21 @@ import { cn } from "@maple/ui/lib/utils"
import { DetailsSection } from "@/components/alerts/details-section"
import { NotificationsSection } from "@/components/alerts/notifications-section"
import { DestinationDialog } from "@/components/alerts/destination-dialog"
import { useDestinationManager } from "@/components/alerts/overview/settings-tab"
import { useDestinationManager } from "@/components/alerts/overview/routing-tab"
import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
import { useAsyncAction } from "@/hooks/use-mutation-action"
import { RuleActionBar } from "@/components/alerts/rule-action-bar"
import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout"
import {
RuleConditionFields,
conditionSubject,
describeRuleCondition,
} from "@/components/alerts/rule-condition-bar"
import { RuleStep } from "@/components/alerts/rule-step"
import { RuleLiveChartHero } from "@/components/alerts/rule-live-chart-hero"
import { RuleNoiseSection } from "@/components/alerts/rule-noise-section"
import { RuleQuerySection } from "@/components/alerts/rule-query-section"
import { RuleTemplatesOverlay } from "@/components/alerts/rule-templates-overlay"
import { ScopeSection } from "@/components/alerts/scope-section"
import { SignalAndThresholdSection } from "@/components/alerts/signal-and-threshold-section"
import { WidgetPrefillNoticeBanner } from "@/components/alerts/widget-prefill-notice-banner"
import { DashboardPage } from "@/components/layout/dashboard-page"
import type { TimeRange } from "@/components/time-range-picker/types"
Expand All @@ -33,18 +39,20 @@ import {
isRangeComparator,
isRulePreviewReady,
pickDefaultDestination,
signalLabels,
ruleToFormState,
type RuleFormState,
} from "@/lib/alerts/form-utils"
import { diffRuleForms, incidentsClearedByDraft } from "@/lib/alerts/preview-insights"
import { toastExit } from "@/lib/error-toast"
import { applyTemplate } from "@/lib/alerts/templates"
import { switchSignalKind } from "@/lib/alerts/rule-modes"
import type { WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill"
import { Result, useAtomSet } from "@/lib/effect-atom"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
import { useAlertRulesList } from "@/hooks/use-alerts-list"
import { useAlertIncidentsList, useAlertRulesList } from "@/hooks/use-alerts-list"

/** Preview lookback the form opens on — the rule detail page's default too. */
const DEFAULT_PREVIEW_PRESET = "24h"
/** Preview lookback the form opens on: a week shows how often the rule would really fire. */
const DEFAULT_PREVIEW_PRESET = "7d"

export function AlertCreateFormSurface({
initialForm,
Expand Down Expand Up @@ -118,6 +126,29 @@ export function AlertCreateFormSurface({

const { preview, previewLoading, previewError } = useAlertRulePreview(ruleForm, previewRange)

// Edit mode replays the saved rule over the same window, but only once the
// draft evaluates differently; until then the two charts would be identical.
const savedForm = useMemo(() => (editingRule ? ruleToFormState(editingRule) : null), [editingRule])
const changes = useMemo(
() => (savedForm === null ? [] : diffRuleForms(savedForm, ruleForm)),
[savedForm, ruleForm],
)
const evaluationChanged =
savedForm !== null && previewIdentityKey(savedForm) !== previewIdentityKey(ruleForm)
const savedReplay = useAlertRulePreview(evaluationChanged ? savedForm : null, previewRange)
const { result: incidentsResult } = useAlertIncidentsList()
const clearedIncidents = useMemo(() => {
if (editingRule === null || !evaluationChanged) return []
const open = Result.builder(incidentsResult)
.onSuccess((response) =>
response.incidents.filter(
(incident) => incident.ruleId === editingRule.id && incident.status === "open",
),
)
.orElse(() => [])
return incidentsClearedByDraft(open, preview)
}, [editingRule, evaluationChanged, incidentsResult, preview])

const validationIssues = useMemo(() => deriveValidationIssues(ruleForm), [ruleForm])

const suggestedName = useMemo(() => makeSuggestedName(ruleForm), [ruleForm])
Expand Down Expand Up @@ -192,10 +223,10 @@ export function AlertCreateFormSurface({
const [previewRule, previewingRule] = useAsyncAction(() => runTest(false))
const [sendTestNotification, sendingTestNotification] = useAsyncAction(() => runTest(true))

const showScope = ruleForm.signalType !== "builder_query" && ruleForm.signalType !== "raw_query"
// Drop the verdict as soon as the user edits anything it depended on.
const currentPreviewKey = previewIdentityKey(ruleForm)
const freshPreviewResult = previewResult?.key === currentPreviewKey ? previewResult : null
const thresholdMissing = !Number.isFinite(Number(ruleForm.threshold)) || ruleForm.threshold.trim() === ""

return (
<>
Expand All @@ -205,40 +236,63 @@ export function AlertCreateFormSurface({
{ label: editingRule ? "Edit rule" : "New rule" },
]}
>
<div className={cn("mx-auto w-full space-y-4", RULE_FORM_MAX_WIDTH)}>
<div className={cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH)}>
<WidgetPrefillNoticeBanner notices={prefillNotices} />
{/* The preview rides beside the form, pinned, so every edit to the signal,
scope or threshold redraws where you can see it. Stacked above below lg. */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">
<div className="lg:sticky lg:top-0 lg:order-2">
<RuleLiveChartHero
form={ruleForm}
preview={preview}
previewLoading={previewLoading}
previewError={previewError}
onTestRule={() => void previewRule()}
testing={previewingRule}
previewResult={freshPreviewResult}
range={previewRange}
timeRange={previewTimeRange}
onTimeRangeChange={setPreviewTimeRange}
/>
<RuleStep
number={1}
divider={false}
title="What to watch"
hint="Pick a built-in signal, or build the query like a dashboard widget."
status="done"
>
<RuleQuerySection
form={ruleForm}
onChange={setRuleForm}
autocompleteValues={autocompleteValues}
serviceNameOptions={serviceNameOptions}
environmentOptions={environmentOptions}
/>
</RuleStep>
<RuleStep
number={2}
title="When to alert"
hint="The dashed line is your threshold. Shaded bands show when the rule would have fired."
status={thresholdMissing ? "missing" : "done"}
>
<div className="space-y-4">
<RuleLiveChartHero
form={ruleForm}
preview={preview}
previewLoading={previewLoading}
previewError={previewError}
onTestRule={() => void previewRule()}
testing={previewingRule}
previewResult={freshPreviewResult}
range={previewRange}
timeRange={previewTimeRange}
onTimeRangeChange={setPreviewTimeRange}
comparison={
savedForm !== null && changes.length > 0
? {
savedPreview: evaluationChanged ? savedReplay.preview : preview,
savedLoading: evaluationChanged && savedReplay.previewLoading,
changes,
clearedIncidents,
}
: null
}
/>
<RuleConditionFields form={ruleForm} onChange={setRuleForm} saved={savedForm} />
<RuleNoiseSection form={ruleForm} onChange={setRuleForm} />
</div>
<div className="min-w-0 space-y-4 lg:order-1">
<SignalAndThresholdSection
form={ruleForm}
onChange={setRuleForm}
autocompleteValues={autocompleteValues}
/>
{showScope && (
<ScopeSection
form={ruleForm}
onChange={setRuleForm}
serviceNameOptions={serviceNameOptions}
environmentOptions={environmentOptions}
autocompleteValues={autocompleteValues}
/>
)}
</RuleStep>
<div className="grid gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start">
<RuleStep
number={3}
divider={false}
title="Who gets notified"
status={ruleForm.destinationIds.length === 0 ? "missing" : "done"}
>
<NotificationsSection
form={ruleForm}
onChange={setRuleForm}
Expand All @@ -252,20 +306,29 @@ export function AlertCreateFormSurface({
}
onQuickCreate={isAdmin ? destinationManager.quickCreate : undefined}
/>
</RuleStep>
<RuleStep
number={4}
divider={false}
title="Name it"
status={ruleForm.name.trim().length === 0 ? "missing" : "done"}
>
<DetailsSection
form={ruleForm}
onChange={setRuleForm}
suggestedName={suggestedName}
tagSuggestions={tagSuggestions}
/>
</div>
</RuleStep>
</div>
</div>

<RuleActionBar
editing={!!editingRule}
changeCount={editingRule ? changes.length : null}
saving={savingRule}
validationIssues={validationIssues}
summary={describeRuleCondition(ruleForm)}
onCancel={() => navigate({ to: "/alerts" })}
onSave={() => void handleSave()}
onShowTemplates={editingRule ? undefined : () => setTemplatesOpen(true)}
Expand All @@ -280,7 +343,13 @@ export function AlertCreateFormSurface({
open={templatesOpen}
onOpenChange={setTemplatesOpen}
onPick={(template) => {
setRuleForm((current) => applyTemplate(template, current))
setRuleForm((current) => {
const applied = applyTemplate(template, current)
// Templates fill the query builder; Apdex has no builder aggregation, so it stays Built-in.
return applied.signalType === "apdex"
? applied
: switchSignalKind(applied, "builder_query").form
})
setTemplatesOpen(false)
}}
onStartBlank={() => setTemplatesOpen(false)}
Expand Down Expand Up @@ -308,14 +377,14 @@ function deriveValidationIssues(form: RuleFormState): string[] {
if (isRangeComparator(form.comparator) && !Number.isFinite(Number(form.thresholdUpper))) {
issues.push("Upper threshold")
}
// The four timing fields silently fall back to a default when they don't parse
// The timing fields silently fall back to a default when they don't parse
// (see `parsePositiveNumber` in form-utils), so name them here rather than
// letting a typo save as a different rule than the one on screen.
for (const [label, value] of [
["Window (min)", form.windowMinutes],
["Breaches to fire", form.consecutiveBreachesRequired],
["Healthy to resolve", form.consecutiveHealthyRequired],
["Renotify (min)", form.renotifyIntervalMinutes],
["Window", form.windowMinutes],
["Checks to fire", form.consecutiveBreachesRequired],
["Checks to resolve", form.consecutiveHealthyRequired],
["Remind interval", form.renotifyIntervalMinutes],
] as const) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) issues.push(label)
Expand Down Expand Up @@ -356,7 +425,8 @@ function previewIdentityKey(form: RuleFormState): string {

function makeSuggestedName(form: RuleFormState): string | null {
if (form.name.trim().length > 0) return null
const base = signalLabels[form.signalType]
const subject = conditionSubject(form)
const base = subject.charAt(0).toUpperCase() + subject.slice(1)
const queryGroupBy =
form.signalType === "builder_query" && form.queryBuilderDraft.addOns?.groupBy
? (form.queryBuilderDraft.groupBy ?? [])
Expand All @@ -375,5 +445,5 @@ function makeSuggestedName(form: RuleFormState): string | null {
: null
const env = !queryOwnsScope && form.environments.length > 0 ? form.environments.join(" · ") : null
const suffix = [scope, env].filter((part) => part !== null).join(" · ")
return suffix.length > 0 ? `${base} — ${suffix}` : base
return suffix.length > 0 ? `${base} · ${suffix}` : base
}
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ describe("deriveRuleInitialization — template deep link", () => {
}),
)

expect(draft.form.signalType).toBe("error_rate")
// A blank rule opens in the query builder with the error-rate aggregation.
expect(draft.form.signalType).toBe("builder_query")
expect(draft.form.queryBuilderDraft.aggregation).toBe("error_rate")
expect(draft.form.name).toBe("")
// No serviceName + unknown template → the overlay still leads the flow.
expect(draft.showTemplatesInitially).toBe(true)
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/alerts/alert-create-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout"
import { DashboardPage } from "@/components/layout/dashboard-page"
import { useAutocompleteValuesContext } from "@/hooks/use-autocomplete-values"
import { defaultRuleForm, ruleToFormState, type RuleFormState } from "@/lib/alerts/form-utils"
import { switchSignalKind } from "@/lib/alerts/rule-modes"
import { ALERT_TEMPLATES, applyTemplate } from "@/lib/alerts/templates"
import { decodeAlertChartFromSearchParam, type AlertChartContext } from "@/lib/alerts/widget-chart-param"
import {
Expand Down Expand Up @@ -210,7 +211,8 @@ export function deriveRuleInitialization({
unknown
>
}): RuleInitialization {
const base = defaultRuleForm(search.serviceName)
// New rules open in the query builder; the default error-rate signal carries over as a query.
const base = switchSignalKind(defaultRuleForm(search.serviceName), "builder_query").form

if (search.ruleId) {
// Terminal: the stream is gone, so "which rule is this?" has no answer
Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/components/alerts/alert-rule-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,14 +270,32 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({
clipToDomain(
incidents.map((incident) => ({
x1: new Date(incident.firstTriggeredAt).getTime(),
x2: incident.resolvedAt ? new Date(incident.resolvedAt).getTime() : domain.max,
x2: incident.resolvedAt
? new Date(incident.resolvedAt).getTime()
: incident.heldSince
? Date.parse(incident.heldSince)
: domain.max,
open: incident.status === "open",
})),
domain,
),
[incidents, domain],
)

// An open incident waiting on telemetry: the tail after `heldSince` is not a breach.
const heldBands = React.useMemo(
() =>
clipToDomain(
incidents.flatMap((incident) =>
incident.status === "open" && incident.heldSince
? [{ x1: Date.parse(incident.heldSince), x2: domain.max }]
: [],
),
domain,
),
[incidents, domain],
)

const wouldFireBands = React.useMemo(() => {
if (!showWouldFire || preview == null) return EMPTY_BANDS
return clipToDomain(
Expand Down Expand Up @@ -517,6 +535,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({
0.06,
"incident-closed",
),
...band(heldBands, palette.muted, 0.16, "incident-held"),
...band(wouldFireBands, palette.destructive, 0.08, "would-fire"),
// Threshold rules. The labels deliberately live in the caption below
// the plot, so they cannot clip at the right edge.
Expand Down Expand Up @@ -623,6 +642,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({
yDomain,
noDataBands,
incidentBands,
heldBands,
wouldFireBands,
threshold,
thresholdUpper,
Expand Down Expand Up @@ -758,15 +778,17 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({
Shaded: rule would have fired (approximate — the live scheduler evaluates every minute).
</p>
)}
{hasSignal && heldBands.length > 0 && (
<p className="text-2xs text-muted-foreground">Grey tail: incident held, waiting on data.</p>
)}
{hasSignal && source === "preview" && noDataBands.length > 0 && (
<p className="text-2xs text-muted-foreground">Hatched: no data received in these windows.</p>
)}
{preview?.truncatedToStart != null && (
<p className="text-2xs text-muted-foreground">
{clampedToPreview ? "Axis starts at " : "Query series starts at "}
{formatTime(Date.parse(preview.truncatedToStart), "tooltip")} — the selected range needs
more evaluation windows than one preview replays. Widen the rule's window or shorten the
range for full coverage.
{formatTime(Date.parse(preview.truncatedToStart), "tooltip")}. The range needs more
windows than one preview replays: widen the rule's window or shorten the range.
</p>
)}

Expand Down
Loading
Loading