diff --git a/messages/en-US.json b/messages/en-US.json
index 24446f5c8..d66e89c4b 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1364,8 +1364,8 @@
"alertingDeleteRule": "Delete alert rule",
"alertingRuleDeleted": "Alert rule deleted",
"alertingRuleSaved": "Alert rule saved",
- "alertingEditRule": "Edit alert rule",
- "alertingCreateRule": "Create alert rule",
+ "alertingEditRule": "Edit Alert Rule",
+ "alertingCreateRule": "Create Alert Rule",
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
"alertingRuleNamePlaceholder": "Production site down",
"alertingRuleEnabled": "Rule enabled",
diff --git a/package.json b/package.json
index 596bc91c0..7d7b3df69 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,6 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
- "@xyflow/react": "^12.8.4",
"arctic": "3.7.0",
"axios": "1.13.5",
"better-sqlite3": "11.9.1",
diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
index 50c612bbf..86d455db7 100644
--- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
+++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
@@ -1,6 +1,7 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
+import HeaderTitle from "@app/components/SettingsSectionTitle";
import { apiResponseToFormValues } from "@app/lib/alertRuleForm";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -59,9 +60,15 @@ export default function EditAlertRulePage() {
if (formValues === undefined) {
return (
-
- {t("loading")}
-
+ <>
+
+
+ {t("loading")}
+
+ >
);
}
@@ -70,13 +77,19 @@ export default function EditAlertRulePage() {
}
return (
-
+ <>
+
+
+ >
);
}
diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx
index babc018fa..9f3f20611 100644
--- a/src/app/[orgId]/settings/alerting/create/page.tsx
+++ b/src/app/[orgId]/settings/alerting/create/page.tsx
@@ -1,23 +1,32 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
+import HeaderTitle from "@app/components/SettingsSectionTitle";
import { defaultFormValues } from "@app/lib/alertRuleForm";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
export default function NewAlertRulePage() {
const params = useParams();
const orgId = params.orgId as string;
+ const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
return (
-
+ <>
+
+
+ >
);
-}
\ No newline at end of file
+}
diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx
index e92980171..8ec323261 100644
--- a/src/components/alert-rule-editor/AlertRuleFields.tsx
+++ b/src/components/alert-rule-editor/AlertRuleFields.tsx
@@ -93,7 +93,7 @@ export function AddActionPanel({
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
- const [selected, setSelected] = useState(null);
+ const [selected, setSelected] = useState("notify");
const isPremiumSelected =
selected !== null && EXTERNAL_IDS.includes(selected as any);
diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
index 6d7d8d076..70667cc2e 100644
--- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
+++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
@@ -8,13 +8,7 @@ import {
} from "@app/components/alert-rule-editor/AlertRuleFields";
import { SettingsContainer } from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle
-} from "@app/components/ui/card";
+import { Card, CardContent } from "@app/components/ui/card";
import {
Form,
FormControl,
@@ -24,291 +18,35 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
-import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import {
buildFormSchema,
defaultFormValues,
formValuesToApiPayload,
- type AlertRuleFormAction,
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type { CreateAlertRuleResponse } from "@server/private/routers/alertRule";
import type { AxiosResponse } from "axios";
-import { cn } from "@app/lib/cn";
-import {
- Background,
- Handle,
- Position,
- ReactFlow,
- ReactFlowProvider,
- useEdgesState,
- useNodesState,
- type Edge,
- type Node,
- type NodeProps,
- type NodeTypes
-} from "@xyflow/react";
-import "@xyflow/react/dist/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
-import { Check, ChevronLeft } from "lucide-react";
+import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useFieldArray, useForm, useWatch } from "react-hook-form";
+import { useMemo, useState, type ReactNode } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
import { useTranslations } from "next-intl";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
+import { SwitchInput } from "@app/components/SwitchInput";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
-type AlertRuleT = ReturnType;
+const FORM_ID = "alert-rule-form";
-export type AlertStepId = "source" | "trigger" | "actions";
-
-type AlertStepNodeData = {
- roleLabel: string;
- title: string;
- subtitle: string;
- configured: boolean;
- accent: string;
- topBorderClass: string;
+type StepAccent = {
+ labelClass: string;
+ icon: typeof Flag;
};
-function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
- if (v.sourceType === "site") {
- if (v.siteIds.length === 0) {
- return t("alertingNodeNotConfigured");
- }
- return t("alertingSummarySites", { count: v.siteIds.length });
- }
- if (v.sourceType === "resource") {
- if (v.resourceIds.length === 0) {
- return t("alertingNodeNotConfigured");
- }
- return t("alertingSummaryResources", { count: v.resourceIds.length });
- }
- if (v.healthCheckIds.length === 0) {
- return t("alertingNodeNotConfigured");
- }
- return t("alertingSummaryHealthChecks", { count: v.healthCheckIds.length });
-}
-
-function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
- switch (v.trigger) {
- case "site_online":
- return t("alertingTriggerSiteOnline");
- case "site_offline":
- return t("alertingTriggerSiteOffline");
- case "site_toggle":
- return t("alertingTriggerSiteToggle");
- case "health_check_healthy":
- return t("alertingTriggerHcHealthy");
- case "health_check_unhealthy":
- return t("alertingTriggerHcUnhealthy");
- case "health_check_toggle":
- return t("alertingTriggerHcToggle");
- case "resource_healthy":
- return t("alertingTriggerResourceHealthy");
- case "resource_unhealthy":
- return t("alertingTriggerResourceUnhealthy");
- case "resource_toggle":
- return t("alertingTriggerResourceToggle");
- default:
- return v.trigger;
- }
-}
-
-function oneActionConfigured(a: AlertRuleFormAction): boolean {
- if (a.type === "notify") {
- return (
- a.userTags.length > 0 ||
- a.roleTags.length > 0 ||
- a.emailTags.length > 0
- );
- }
-
- try {
- new URL(a.url.trim());
- return true;
- } catch {
- return false;
- }
-}
-
-function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
- switch (a.type) {
- case "notify":
- return t("alertingActionNotify");
- case "webhook":
- return t("alertingActionWebhook");
- }
-}
-
-function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
- if (a.type === "notify") {
- if (
- a.userTags.length === 0 &&
- a.roleTags.length === 0 &&
- a.emailTags.length === 0
- ) {
- return t("alertingNodeNotConfigured");
- }
- const parts: string[] = [];
- if (a.userTags.length > 0) {
- parts.push(t("alertingUsersSelected", { count: a.userTags.length }));
- }
- if (a.roleTags.length > 0) {
- parts.push(t("alertingRolesSelected", { count: a.roleTags.length }));
- }
- if (a.emailTags.length > 0) {
- parts.push(
- `${t("alertingNotifyEmails")} (${a.emailTags.length})`
- );
- }
- return parts.join(" ยท ");
- }
- const url = a.url.trim();
- if (!url) {
- return t("alertingNodeNotConfigured");
- }
- try {
- return new URL(url).hostname;
- } catch {
- return t("alertingNodeNotConfigured");
- }
-}
-
-function stepConfigured(
- step: "source" | "trigger",
- v: AlertRuleFormValues
-): boolean {
- if (step === "source") {
- return v.sourceType === "site"
- ? v.siteIds.length > 0
- : v.healthCheckIds.length > 0;
- }
- return Boolean(v.trigger);
-}
-
-function buildActionStepNodeData(
- index: number,
- action: AlertRuleFormAction,
- t: AlertRuleT
-): AlertStepNodeData {
- return {
- roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`,
- title: actionTypeLabel(action, t),
- subtitle: summarizeOneAction(action, t),
- configured: oneActionConfigured(action),
- accent: "text-amber-600 dark:text-amber-400",
- topBorderClass: "border-t-amber-500"
- };
-}
-
-function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData {
- return {
- roleLabel: t("alertingNodeRoleAction"),
- title: t("alertingSectionActions"),
- subtitle: t("alertingNodeNotConfigured"),
- configured: false,
- accent: "text-amber-600 dark:text-amber-400",
- topBorderClass: "border-t-amber-500"
- };
-}
-
-const AlertStepNode = memo(function AlertStepNodeFn({
- data,
- selected
-}: NodeProps>) {
- return (
-
-
- {data.configured && (
-
- )}
-
- {data.roleLabel}
-
-
{data.title}
-
- {data.subtitle}
-
-
-
- );
-});
-
-const nodeTypes: NodeTypes = {
- alertStep: AlertStepNode
-};
-
-const ACTION_NODE_X_GAP = 280;
-const ACTION_NODE_Y = 468;
-const SOURCE_NODE_POS = { x: 120, y: 28 };
-const TRIGGER_NODE_POS = { x: 120, y: 248 };
-
-function buildNodeData(
- stepId: "source" | "trigger",
- v: AlertRuleFormValues,
- t: AlertRuleT
-): AlertStepNodeData {
- const accents: Record<
- "source" | "trigger",
- { accent: string; topBorderClass: string; role: string; title: string }
- > = {
- source: {
- accent: "text-blue-600 dark:text-blue-400",
- topBorderClass: "border-t-blue-500",
- role: t("alertingNodeRoleSource"),
- title: t("alertingSectionSource")
- },
- trigger: {
- accent: "text-emerald-600 dark:text-emerald-400",
- topBorderClass: "border-t-emerald-500",
- role: t("alertingNodeRoleTrigger"),
- title: t("alertingSectionTrigger")
- }
- };
- const meta = accents[stepId];
- const subtitle =
- stepId === "source"
- ? summarizeSource(v, t)
- : summarizeTrigger(v, t);
- return {
- roleLabel: meta.role,
- title: meta.title,
- subtitle,
- configured: stepConfigured(stepId, v),
- accent: meta.accent,
- topBorderClass: meta.topBorderClass
- };
-}
-
type AlertRuleGraphEditorProps = {
orgId: string;
alertRuleId?: number;
@@ -317,7 +55,53 @@ type AlertRuleGraphEditorProps = {
disabled?: boolean;
};
-const FORM_ID = "alert-rule-graph-form";
+function VerticalRuleStep({
+ stepNumber,
+ isLast,
+ title,
+ accent,
+ children
+}: {
+ stepNumber: number;
+ isLast: boolean;
+ title: string;
+ accent: StepAccent;
+ children: ReactNode;
+}) {
+ const Icon = accent.icon;
+ return (
+
+
+
+ {stepNumber}
+
+ {!isLast && (
+
+ )}
+
+
+
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+}
export default function AlertRuleGraphEditor({
orgId,
@@ -341,180 +125,6 @@ export default function AlertRuleGraphEditor({
name: "actions"
});
- const wName = useWatch({ control: form.control, name: "name" }) ?? "";
- const wEnabled =
- useWatch({ control: form.control, name: "enabled" }) ?? true;
- const wSourceType =
- useWatch({ control: form.control, name: "sourceType" }) ?? "site";
- const wAllSites =
- useWatch({ control: form.control, name: "allSites" }) ?? true;
- const wSiteIds =
- useWatch({ control: form.control, name: "siteIds" }) ?? [];
- const wAllHealthChecks =
- useWatch({ control: form.control, name: "allHealthChecks" }) ?? true;
- const wHealthCheckIds =
- useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
- const wAllResources =
- useWatch({ control: form.control, name: "allResources" }) ?? true;
- const wResourceIds =
- useWatch({ control: form.control, name: "resourceIds" }) ?? [];
- const wTrigger =
- useWatch({ control: form.control, name: "trigger" }) ??
- "site_toggle";
- const wActions =
- useWatch({ control: form.control, name: "actions" }) ?? [];
-
- const flowValues: AlertRuleFormValues = useMemo(
- () => ({
- name: wName,
- enabled: wEnabled,
- sourceType: wSourceType,
- allSites: wAllSites,
- siteIds: wSiteIds,
- allHealthChecks: wAllHealthChecks,
- healthCheckIds: wHealthCheckIds,
- allResources: wAllResources,
- resourceIds: wResourceIds,
- trigger: wTrigger,
- actions: wActions
- }),
- [
- wName,
- wEnabled,
- wSourceType,
- wAllSites,
- wSiteIds,
- wAllHealthChecks,
- wHealthCheckIds,
- wAllResources,
- wResourceIds,
- wTrigger,
- wActions
- ]
- );
-
- const [selectedStep, setSelectedStep] = useState("source");
-
- const [nodes, setNodes, onNodesChange] = useNodesState([]);
- const [edges, setEdges, onEdgesChange] = useEdgesState([]);
-
- const nodesSyncKeyRef = useRef("");
- useEffect(() => {
- const key = JSON.stringify({ flowValues, selectedStep });
- if (key === nodesSyncKeyRef.current) {
- return;
- }
- nodesSyncKeyRef.current = key;
-
- const nActions = flowValues.actions.length;
- const actionNodes: Node[] =
- nActions === 0
- ? [
- {
- id: "actions",
- type: "alertStep",
- position: {
- x: TRIGGER_NODE_POS.x,
- y: ACTION_NODE_Y
- },
- data: buildActionsPlaceholderNodeData(t),
- selected:
- selectedStep === "actions" ||
- selectedStep.startsWith("action-")
- }
- ]
- : flowValues.actions.map((action, i) => {
- const totalWidth =
- (nActions - 1) * ACTION_NODE_X_GAP;
- const originX =
- TRIGGER_NODE_POS.x - totalWidth / 2;
- return {
- id: `action-${i}`,
- type: "alertStep",
- position: {
- x: originX + i * ACTION_NODE_X_GAP,
- y: ACTION_NODE_Y
- },
- data: buildActionStepNodeData(i, action, t),
- selected: selectedStep === `action-${i}`
- };
- });
-
- setNodes([
- {
- id: "source",
- type: "alertStep",
- position: SOURCE_NODE_POS,
- data: buildNodeData("source", flowValues, t),
- selected: selectedStep === "source"
- },
- {
- id: "trigger",
- type: "alertStep",
- position: TRIGGER_NODE_POS,
- data: buildNodeData("trigger", flowValues, t),
- selected: selectedStep === "trigger"
- },
- ...actionNodes
- ]);
-
- const nextEdges: Edge[] = [
- {
- id: "e-src-trg",
- source: "source",
- target: "trigger",
- animated: true
- },
- ...(nActions === 0
- ? [
- {
- id: "e-trg-act",
- source: "trigger",
- target: "actions",
- animated: true
- } as const
- ]
- : flowValues.actions.map((_, i) => ({
- id: `e-trg-act-${i}`,
- source: "trigger",
- target: `action-${i}`,
- animated: true
- })))
- ];
- setEdges(nextEdges);
- }, [flowValues, selectedStep, t, setNodes, setEdges]);
-
- useEffect(() => {
- if (selectedStep === "actions" && wActions.length > 0) {
- setSelectedStep("action-0");
- }
- }, [selectedStep, wActions.length]);
-
- useEffect(() => {
- if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) {
- setSelectedStep("actions");
- }
- }, [wActions.length, selectedStep]);
-
- useEffect(() => {
- const m = /^action-(\d+)$/.exec(selectedStep);
- if (!m) {
- return;
- }
- const i = parseInt(m[1], 10);
- if (i >= wActions.length) {
- setSelectedStep(
- wActions.length > 0
- ? `action-${wActions.length - 1}`
- : "actions"
- );
- }
- }, [wActions.length, selectedStep]);
-
- const onNodeClick = useCallback((_event: unknown, node: Node) => {
- setSelectedStep(node.id);
- }, []);
-
const onSubmit = form.handleSubmit(async (values) => {
setIsSaving(true);
try {
@@ -545,173 +155,158 @@ export default function AlertRuleGraphEditor({
}
});
- const isActionsSidebar =
- selectedStep === "actions" || selectedStep.startsWith("action-");
-
- const sidebarTitle = isActionsSidebar
- ? t("alertingConfigureActions")
- : selectedStep === "source"
- ? t("alertingConfigureSource")
- : t("alertingConfigureTrigger");
-
return (