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 (
    - - -
    -
    -
    - - {isNew && ( - - {t("alertingDraftBadge")} - - )} -
    - ( - - - {t("name")} - - - - - - - )} - /> -
    - ( - - - {t("alertingRuleEnabled")} - - - - - - )} - /> - -
    -
    -
    -
    -
    - -
    - - - - {t("alertingGraphCanvasTitle")} - - - {t("alertingGraphCanvasDescription")} - - - -
    - - +
    -
    -
    + {isSaving ? t("saving") : t("save")} + + + + + - - - - {sidebarTitle} - - - {t("alertingSidebarHint")} - - - -
    -
    - {selectedStep === "source" && ( - - )} - {selectedStep === "trigger" && ( - - )} - {isActionsSidebar && ( -
    - - {t("alertingSectionActions")} - +
    +
      + +
      + +
      +
      + +
      + +
      +
      + +
      +
      { - const newIndex = - fields.length; if (type === "notify") { append({ type: "notify", @@ -732,14 +327,14 @@ export default function AlertRuleGraphEditor({ ], authType: "none", bearerToken: "", - basicCredentials: "", - customHeaderName: "", - customHeaderValue: "" + basicCredentials: + "", + customHeaderName: + "", + customHeaderValue: + "" }); } - setSelectedStep( - `action-${newIndex}` - ); }} /> {fields.map((f, index) => ( @@ -759,11 +354,10 @@ export default function AlertRuleGraphEditor({ /> ))}
      - )} -
    -
    -
    -
    + + + +
    diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index f7f96e927..115c9fcf5 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -317,14 +317,7 @@ export function defaultFormValues(): AlertRuleFormValues { allResources: true, resourceIds: [], trigger: "site_toggle", - actions: [ - { - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - } - ] + actions: [] }; } @@ -379,16 +372,6 @@ export function apiResponseToFormValues( }); } - // Always ensure at least one action so the form is valid - if (actions.length === 0) { - actions.push({ - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - }); - } - const allSites = sourceType === "site" && rule.siteIds.length === 0; const allHealthChecks = sourceType === "health_check" && rule.healthCheckIds.length === 0;