diff --git a/messages/en-US.json b/messages/en-US.json index b2f750cb1..753437a35 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1237,6 +1237,7 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", + "search": "Search…", "searchPlaceholder": "Search...", "emptySearchOptions": "No options found", "create": "Create", @@ -1327,10 +1328,10 @@ "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "alertingTitle": "Alerting rules", - "alertingDescription": "Define sources, triggers, and actions for notifications. Rules are stored locally in this browser until server-side alerting is available.", + "alertingDescription": "Define sources, triggers, and actions for notifications.", "alertingRules": "Alert rules", "alertingSearchRules": "Search rules…", - "alertingAddRule": "Create rule", + "alertingAddRule": "Create Rule", "alertingColumnSource": "Source", "alertingColumnTrigger": "Trigger", "alertingColumnActions": "Actions", @@ -1350,6 +1351,10 @@ "alertingSourceHealthCheck": "Health check", "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", + "alertingPickResources": "Resources", + "alertingSelectResources": "Select resources…", + "alertingResourcesSelected": "{count} resources selected", + "alertingResourcesEmpty": "No resources with targets in the first 10 results.", "alertingSectionTrigger": "Trigger", "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", @@ -1378,6 +1383,7 @@ "alertingSelectHealthChecks": "Select health checks…", "alertingHealthChecksSelected": "{count} health checks selected", "alertingNoHealthChecks": "No targets with health checks enabled", + "alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.", "alertingSelectUsers": "Select users…", "alertingUsersSelected": "{count} users selected", "alertingSelectRoles": "Select roles…", @@ -1393,6 +1399,19 @@ "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingErrorSmsPhones": "Add at least one phone number", "alertingErrorWebhookUrl": "Enter a valid webhook URL", + "alertingConfigureSource": "Configure Source", + "alertingConfigureTrigger": "Configure Trigger", + "alertingConfigureActions": "Configure Actions", + "alertingBackToRules": "Back to Rules", + "alertingDraftBadge": "Draft — save to store this rule", + "alertingSidebarHint": "Click a step on the canvas to edit it here.", + "alertingGraphCanvasTitle": "Rule Flow", + "alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.", + "alertingNodeNotConfigured": "Not configured yet", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "Source", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Action", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/package-lock.json b/package-lock.json index 7b63f1691..69a0b5a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@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", @@ -1058,6 +1059,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2353,6 +2355,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2375,6 +2378,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2397,6 +2401,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2413,6 +2418,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2429,6 +2435,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2445,6 +2452,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2461,6 +2469,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2477,6 +2486,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2493,6 +2503,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2509,6 +2520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2525,6 +2537,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2541,6 +2554,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2563,6 +2577,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2585,6 +2600,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2607,6 +2623,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2629,6 +2646,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2651,6 +2669,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2673,6 +2692,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2695,6 +2715,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2714,6 +2735,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2733,6 +2755,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2752,6 +2775,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3002,6 +3026,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6948,6 +6973,7 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8408,6 +8434,7 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8523,6 +8550,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8682,7 +8710,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8798,7 +8825,6 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { @@ -8833,7 +8859,6 @@ "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8843,7 +8868,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -8870,6 +8894,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8965,6 +8990,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -8992,6 +9018,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9017,6 +9044,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9027,6 +9055,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9113,8 +9142,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9188,6 +9216,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9642,6 +9671,38 @@ "win32" ] }, + "node_modules/@xyflow/react": { + "version": "12.8.4", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz", + "integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.68", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.68", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz", + "integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -9661,6 +9722,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10109,6 +10171,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10180,6 +10243,7 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10308,6 +10372,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10490,6 +10555,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -11214,6 +11285,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -11654,7 +11726,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "engines": { "node": ">=20" }, @@ -12289,6 +12360,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12374,6 +12446,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12510,6 +12583,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12903,6 +12977,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -15320,7 +15395,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15331,7 +15405,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15419,6 +15492,7 @@ "version": "15.5.12", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "peer": true, "dependencies": { "@next/env": "15.5.12", "@swc/helpers": "0.5.15", @@ -16377,6 +16451,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16881,6 +16956,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16912,6 +16988,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17204,6 +17281,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18665,7 +18743,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -19140,6 +19219,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19567,6 +19647,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19773,6 +19854,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -19788,6 +19870,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 05ae3b49f..7f7900eb5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@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/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0263d2b72..04d347698 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -340,15 +340,16 @@ export default function Page() { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); - const res = await api.post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleIds, - validHours: parseInt(values.validForHours), - sendEmail - } - ) + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx new file mode 100644 index 000000000..b9379f7cc --- /dev/null +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import { ruleToFormValues } from "@app/lib/alertRuleForm"; +import type { AlertRule } from "@app/lib/alertRulesLocalStorage"; +import { getRule } from "@app/lib/alertRulesLocalStorage"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export default function EditAlertRulePage() { + const t = useTranslations(); + const params = useParams(); + const router = useRouter(); + const orgId = params.orgId as string; + const ruleId = params.ruleId as string; + const [rule, setRule] = useState(undefined); + + useEffect(() => { + const r = getRule(orgId, ruleId); + setRule(r ?? null); + }, [orgId, ruleId]); + + useEffect(() => { + if (rule === null) { + router.replace(`/${orgId}/settings/alerting`); + } + }, [rule, orgId, router]); + + if (rule === undefined) { + return ( +
+ {t("loading")} +
+ ); + } + + if (rule === null) { + return null; + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx new file mode 100644 index 000000000..24c0d2ffe --- /dev/null +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import { defaultFormValues } from "@app/lib/alertRuleForm"; +import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export default function NewAlertRulePage() { + const t = useTranslations(); + const params = useParams(); + const orgId = params.orgId as string; + const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>( + null + ); + + useEffect(() => { + setMeta({ id: newRuleId(), createdAt: isoNow() }); + }, []); + + if (!meta) { + return ( +
+ {t("loading")} +
+ ); + } + + return ( + + ); +} diff --git a/src/components/AlertRuleCredenza.tsx b/src/components/AlertRuleCredenza.tsx deleted file mode 100644 index 141011cb1..000000000 --- a/src/components/AlertRuleCredenza.tsx +++ /dev/null @@ -1,1431 +0,0 @@ -"use client"; - -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { Button } from "@app/components/ui/button"; -import { Checkbox } from "@app/components/ui/checkbox"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Separator } from "@app/components/ui/separator"; -import { Switch } from "@app/components/ui/switch"; -import { TagInput, type Tag } from "@app/components/tags/tag-input"; -import { toast } from "@app/hooks/useToast"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { - type AlertRule, - type AlertTrigger, - isoNow, - newRuleId, - upsertRule -} from "@app/lib/alertRulesLocalStorage"; -import { orgQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useState } from "react"; -import type { Control, UseFormReturn } from "react-hook-form"; -import { useFieldArray, useForm } from "react-hook-form"; -import { useDebounce } from "use-debounce"; -import { z } from "zod"; - -const FORM_ID = "alert-rule-form"; - -const tagSchema = z.object({ - id: z.string(), - text: z.string() -}); - -type FormAction = - | { - type: "notify"; - userIds: string[]; - roleIds: number[]; - emailTags: Tag[]; - } - | { type: "sms"; phoneTags: Tag[] } - | { - type: "webhook"; - url: string; - method: string; - headers: { key: string; value: string }[]; - secret: string; - }; - -export type AlertRuleFormValues = { - name: string; - enabled: boolean; - sourceType: "site" | "health_check"; - siteIds: number[]; - targetIds: number[]; - trigger: AlertTrigger; - actions: FormAction[]; -}; - -function buildFormSchema(t: (k: string) => string) { - return z - .object({ - name: z.string().min(1, { message: t("alertingErrorNameRequired") }), - enabled: z.boolean(), - sourceType: z.enum(["site", "health_check"]), - siteIds: z.array(z.number()), - targetIds: z.array(z.number()), - trigger: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_unhealthy" - ]), - actions: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userIds: z.array(z.string()), - roleIds: z.array(z.number()), - emailTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("sms"), - phoneTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("webhook"), - url: z.string(), - method: z.string(), - headers: z.array( - z.object({ - key: z.string(), - value: z.string() - }) - ), - secret: z.string() - }) - ]) - ) - .min(1, { message: t("alertingErrorActionsMin") }) - }) - .superRefine((val, ctx) => { - if (val.sourceType === "site" && val.siteIds.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorPickSites"), - path: ["siteIds"] - }); - } - if ( - val.sourceType === "health_check" && - val.targetIds.length === 0 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorPickHealthChecks"), - path: ["targetIds"] - }); - } - const siteTriggers: AlertTrigger[] = [ - "site_online", - "site_offline" - ]; - const hcTriggers: AlertTrigger[] = [ - "health_check_healthy", - "health_check_unhealthy" - ]; - if ( - val.sourceType === "site" && - !siteTriggers.includes(val.trigger) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorTriggerSite"), - path: ["trigger"] - }); - } - if ( - val.sourceType === "health_check" && - !hcTriggers.includes(val.trigger) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorTriggerHealth"), - path: ["trigger"] - }); - } - val.actions.forEach((a, i) => { - if (a.type === "notify") { - if ( - a.userIds.length === 0 && - a.roleIds.length === 0 && - a.emailTags.length === 0 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorNotifyRecipients"), - path: ["actions", i, "userIds"] - }); - } - } - if (a.type === "sms" && a.phoneTags.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorSmsPhones"), - path: ["actions", i, "phoneTags"] - }); - } - if (a.type === "webhook") { - try { - // eslint-disable-next-line no-new - new URL(a.url.trim()); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorWebhookUrl"), - path: ["actions", i, "url"] - }); - } - } - }); - }); -} - -function defaultFormValues(): AlertRuleFormValues { - return { - name: "", - enabled: true, - sourceType: "site", - siteIds: [], - targetIds: [], - trigger: "site_offline", - actions: [ - { - type: "notify", - userIds: [], - roleIds: [], - emailTags: [] - } - ] - }; -} - -function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { - const actions: FormAction[] = rule.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify", - userIds: a.userIds.map(String), - roleIds: [...a.roleIds], - emailTags: a.emails.map((e) => ({ id: e, text: e })) - }; - } - if (a.type === "sms") { - return { - type: "sms", - phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) - }; - } - return { - type: "webhook", - url: a.url, - method: a.method, - headers: - a.headers.length > 0 - ? a.headers.map((h) => ({ ...h })) - : [{ key: "", value: "" }], - secret: a.secret ?? "" - }; - }); - return { - name: rule.name, - enabled: rule.enabled, - sourceType: rule.source.type, - siteIds: - rule.source.type === "site" ? [...rule.source.siteIds] : [], - targetIds: - rule.source.type === "health_check" - ? [...rule.source.targetIds] - : [], - trigger: rule.trigger, - actions - }; -} - -function formValuesToRule( - v: AlertRuleFormValues, - id: string, - createdAt: string -): AlertRule { - const source = - v.sourceType === "site" - ? { type: "site" as const, siteIds: v.siteIds } - : { - type: "health_check" as const, - targetIds: v.targetIds - }; - const actions = v.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify" as const, - userIds: a.userIds, - roleIds: a.roleIds, - emails: a.emailTags.map((t) => t.text.trim()).filter(Boolean) - }; - } - if (a.type === "sms") { - return { - type: "sms" as const, - phoneNumbers: a.phoneTags - .map((t) => t.text.trim()) - .filter(Boolean) - }; - } - return { - type: "webhook" as const, - url: a.url.trim(), - method: a.method, - headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), - secret: a.secret.trim() || undefined - }; - }); - return { - id, - name: v.name.trim(), - enabled: v.enabled, - createdAt, - updatedAt: isoNow(), - source, - trigger: v.trigger, - actions - }; -} - -type TargetRow = { - targetId: number; - resourceName: string; - ip: string; - port: number; -}; - -function useHealthCheckOptions(orgId: string) { - const { data: resources = [] } = useQuery( - orgQueries.resources({ orgId, perPage: 10_000 }) - ); - return useMemo(() => { - const rows: TargetRow[] = []; - for (const r of resources) { - for (const t of r.targets) { - const ext = t as typeof t & { hcEnabled?: boolean }; - if (ext.hcEnabled === true) { - rows.push({ - targetId: t.targetId, - resourceName: r.name, - ip: t.ip, - port: t.port - }); - } - } - } - return rows; - }, [resources]); -} - -type AlertRuleCredenzaProps = { - open: boolean; - setOpen: (open: boolean) => void; - orgId: string; - rule: AlertRule | null; - onSaved: () => void; -}; - -export default function AlertRuleCredenza({ - open, - setOpen, - orgId, - rule, - onSaved -}: AlertRuleCredenzaProps) { - const t = useTranslations(); - const schema = useMemo(() => buildFormSchema(t), [t]); - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: defaultFormValues() - }); - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "actions" - }); - - const sourceType = form.watch("sourceType"); - const trigger = form.watch("trigger"); - - const ruleKey = rule?.id ?? "__new__"; - useEffect(() => { - if (!open) return; - if (rule) { - form.reset(ruleToFormValues(rule)); - } else { - form.reset(defaultFormValues()); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reset when opening or switching create/edit target - }, [open, ruleKey]); - - useEffect(() => { - if (sourceType === "site") { - if ( - trigger !== "site_online" && - trigger !== "site_offline" - ) { - form.setValue("trigger", "site_offline"); - } - } else if ( - trigger !== "health_check_healthy" && - trigger !== "health_check_unhealthy" - ) { - form.setValue("trigger", "health_check_unhealthy"); - } - }, [sourceType, trigger, form]); - - const onSubmit = form.handleSubmit((values) => { - const id = rule?.id ?? newRuleId(); - const createdAt = rule?.createdAt ?? isoNow(); - const next = formValuesToRule(values, id, createdAt); - upsertRule(orgId, next); - toast({ title: t("alertingRuleSaved") }); - onSaved(); - setOpen(false); - }); - - return ( - - - - - {rule - ? t("alertingEditRule") - : t("alertingCreateRule")} - - - {t("alertingRuleCredenzaDescription")} - - - -
- - ( - - {t("name")} - - - - - - )} - /> - ( - - - {t("alertingRuleEnabled")} - - - - - - )} - /> - -
-

- {t("alertingSectionSource")} -

- ( - - - {t("alertingSourceType")} - - - - - )} - /> - {sourceType === "site" ? ( - ( - - - {t("alertingPickSites")} - - - - - )} - /> - ) : ( - ( - - - {t( - "alertingPickHealthChecks" - )} - - - - - )} - /> - )} -
- - - -
-

- {t("alertingSectionTrigger")} -

- ( - - - {t("alertingTrigger")} - - - - - )} - /> -
- - - -
-
-

- {t("alertingSectionActions")} -

- { - if (type === "notify") { - append({ - type: "notify", - userIds: [], - roleIds: [], - emailTags: [] - }); - } else if (type === "sms") { - append({ - type: "sms", - phoneTags: [] - }); - } else { - append({ - type: "webhook", - url: "", - method: "POST", - headers: [ - { key: "", value: "" } - ], - secret: "" - }); - } - }} - /> -
- {fields.map((f, index) => ( - remove(index)} - canRemove={fields.length > 1} - /> - ))} -
- - -
- - - - - - -
-
- ); -} - -function DropdownAddAction({ - onPick -}: { - onPick: (type: "notify" | "sms" | "webhook") => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - return ( - - - - - -
- - - -
-
-
- ); -} - -function SiteMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const [debounced] = useDebounce(q, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ orgId, query: debounced, perPage: 500 }) - ); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectSites") - : t("alertingSitesSelected", { count: value.length }); - return ( - - - - - - - - - {t("siteNotFound")} - - {sites.map((s) => ( - toggle(s.siteId)} - className="cursor-pointer" - > - - {s.name} - - ))} - - - - - - ); -} - -function HealthCheckMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const rows = useHealthCheckOptions(orgId); - const filtered = useMemo(() => { - const qq = q.trim().toLowerCase(); - if (!qq) return rows; - return rows.filter( - (r) => - r.resourceName.toLowerCase().includes(qq) || - `${r.ip}:${r.port}`.includes(qq) - ); - }, [rows, q]); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectHealthChecks") - : t("alertingHealthChecksSelected", { count: value.length }); - return ( - - - - - - - - - - {t("alertingNoHealthChecks")} - - - {filtered.map((r) => ( - toggle(r.targetId)} - className="cursor-pointer" - > - - - {r.resourceName} · {r.ip}:{r.port} - - - ))} - - - - - - ); -} - -function ActionBlock({ - orgId, - index, - control, - form, - onRemove, - canRemove -}: { - orgId: string; - index: number; - control: Control; - form: UseFormReturn; - onRemove: () => void; - canRemove: boolean; -}) { - const t = useTranslations(); - const type = form.watch(`actions.${index}.type`); - return ( -
- {canRemove && ( - - )} - ( - - {t("alertingActionType")} - - - )} - /> - {type === "notify" && ( - - )} - {type === "sms" && ( - - )} - {type === "webhook" && ( - - )} -
- ); -} - -function NotifyActionFields({ - orgId, - index, - control, - form -}: { - orgId: string; - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const [emailActiveIdx, setEmailActiveIdx] = useState(null); - const userIds = form.watch(`actions.${index}.userIds`) ?? []; - const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; - const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; - - return ( -
- - {t("alertingNotifyUsers")} - - form.setValue(`actions.${index}.userIds`, ids) - } - /> - - - {t("alertingNotifyRoles")} - - form.setValue(`actions.${index}.roleIds`, ids) - } - /> - - ( - - {t("alertingNotifyEmails")} - - { - const next = - typeof updater === "function" - ? updater(emailTags) - : updater; - form.setValue( - `actions.${index}.emailTags`, - next - ); - }} - activeTagIndex={emailActiveIdx} - setActiveTagIndex={setEmailActiveIdx} - placeholder={t( - "alertingEmailPlaceholder" - )} - delimiterList={[",", "Enter"]} - /> - - - - )} - /> -
- ); -} - -function SmsActionFields({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); - const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; - return ( - ( - - {t("alertingSmsNumbers")} - - { - const next = - typeof updater === "function" - ? updater(phoneTags) - : updater; - form.setValue( - `actions.${index}.phoneTags`, - next - ); - }} - activeTagIndex={phoneActiveIdx} - setActiveTagIndex={setPhoneActiveIdx} - placeholder={t("alertingSmsPlaceholder")} - delimiterList={[",", "Enter"]} - /> - - - - )} - /> - ); -} - -function WebhookActionFields({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - return ( -
- ( - - URL - - - - - - )} - /> - ( - - {t("alertingWebhookMethod")} - - - - )} - /> - ( - - {t("alertingWebhookSecret")} - - - - - - )} - /> - -
- ); -} - -function WebhookHeadersField({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const headers = - form.watch(`actions.${index}.headers` as const) ?? []; - return ( -
- {t("alertingWebhookHeaders")} - {headers.map((_, hi) => ( -
- ( - - - - - - )} - /> - ( - - - - - - )} - /> - -
- ))} - -
- ); -} - -function UserMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: string[]; - onChange: (v: string[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const [debounced] = useDebounce(q, 150); - const { data: users = [] } = useQuery(orgQueries.users({ orgId })); - const shown = useMemo(() => { - const qq = debounced.trim().toLowerCase(); - if (!qq) return users.slice(0, 200); - return users - .filter((u) => { - const label = getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - }).toLowerCase(); - return ( - label.includes(qq) || - (u.email ?? "").toLowerCase().includes(qq) - ); - }) - .slice(0, 200); - }, [users, debounced]); - const toggle = (id: string) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectUsers") - : t("alertingUsersSelected", { count: value.length }); - return ( - - - - - - - - - {t("noResults")} - - {shown.map((u) => { - const uid = String(u.id); - return ( - toggle(uid)} - className="cursor-pointer" - > - - {getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - })} - - ); - })} - - - - - - ); -} - -function RoleMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectRoles") - : t("alertingRolesSelected", { count: value.length }); - return ( - - - - - - - - - {roles.map((r) => ( - toggle(r.roleId)} - className="cursor-pointer" - > - - {r.name} - - ))} - - - - - - ); -} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 5a6b0f060..97e1d1755 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -1,12 +1,8 @@ "use client"; -import AlertRuleCredenza from "@app/components/AlertRuleCredenza"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { - DataTable, - ExtendedColumnDef -} from "@app/components/ui/data-table"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -24,6 +20,8 @@ import { } from "@app/lib/alertRulesLocalStorage"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import moment from "moment"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@app/components/ui/badge"; @@ -32,7 +30,14 @@ type AlertingRulesTableProps = { orgId: string; }; -function sourceSummary(rule: AlertRule, t: (k: string, o?: Record) => string) { +function ruleHref(orgId: string, ruleId: string) { + return `/${orgId}/settings/alerting/${ruleId}`; +} + +function sourceSummary( + rule: AlertRule, + t: (k: string, o?: Record) => string +) { if (rule.source.type === "site") { return t("alertingSummarySites", { count: rule.source.siteIds.length @@ -83,10 +88,9 @@ function actionBadges(rule: AlertRule, t: (k: string) => string) { } export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { + const router = useRouter(); const t = useTranslations(); const [rows, setRows] = useState([]); - const [credenzaOpen, setCredenzaOpen] = useState(false); - const [credenzaRule, setCredenzaRule] = useState(null); const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); @@ -146,10 +150,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { id: "source", friendlyName: t("alertingColumnSource"), - header: () => {t("alertingColumnSource")}, - cell: ({ row }) => ( - {sourceSummary(row.original, t)} - ) + header: () => ( + {t("alertingColumnSource")} + ), + cell: ({ row }) => {sourceSummary(row.original, t)} }, { id: "trigger", @@ -190,9 +194,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { accessorKey: "createdAt", friendlyName: t("createdAt"), - header: () => ( - {t("createdAt")} - ), + header: () => {t("createdAt")}, cell: ({ row }) => ( {moment(row.original.createdAt).format("lll")} ) @@ -204,13 +206,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { cell: ({ row }) => { const r = row.original; return ( -
+
- - { - setCredenzaRule(r); - setCredenzaOpen(true); - }} - > - {t("edit")} - { setSelected(r); @@ -238,6 +229,11 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { +
); } @@ -246,16 +242,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { return ( <> - { - setCredenzaOpen(v); - if (!v) setCredenzaRule(null); - }} - orgId={orgId} - rule={credenzaRule} - onSaved={refreshFromStorage} - /> {selected && ( { - setCredenzaRule(null); - setCredenzaOpen(true); + router.push(`/${orgId}/settings/alerting/create`); }} onRefresh={refreshData} isRefreshing={isRefreshing} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx new file mode 100644 index 000000000..5a2e42393 --- /dev/null +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -0,0 +1,981 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { TagInput } from "@app/components/tags/tag-input"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { + type AlertRuleFormAction, + type AlertRuleFormValues +} from "@app/lib/alertRuleForm"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import type { Control, UseFormReturn } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useDebounce } from "use-debounce"; + +export function DropdownAddAction({ + onPick +}: { + onPick: (type: "notify" | "sms" | "webhook") => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + return ( + + + + + +
+ + + +
+
+
+ ); +} + +function SiteMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: sites = [] } = useQuery( + orgQueries.sites({ orgId, query: debounced, perPage: 500 }) + ); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectSites") + : t("alertingSitesSelected", { count: value.length }); + return ( + + + + + + + + + {t("siteNotFound")} + + {sites.map((s) => ( + toggle(s.siteId)} + className="cursor-pointer" + > + + {s.name} + + ))} + + + + + + ); +} + +const ALERT_RESOURCES_PAGE_SIZE = 10; + +function ResourceTenMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: resources = [] } = useQuery( + orgQueries.resources({ + orgId, + perPage: ALERT_RESOURCES_PAGE_SIZE + }) + ); + const rows = useMemo(() => { + const out: { + resourceId: number; + name: string; + targetIds: number[]; + }[] = []; + for (const r of resources) { + const targetIds = r.targets.map((x) => x.targetId); + if (targetIds.length > 0) { + out.push({ + resourceId: r.resourceId, + name: r.name, + targetIds + }); + } + } + return out; + }, [resources]); + + const selectedResourceCount = useMemo( + () => + rows.filter( + (row) => + row.targetIds.length > 0 && + row.targetIds.every((id) => value.includes(id)) + ).length, + [rows, value] + ); + + const toggle = (targetIds: number[]) => { + const allOn = + targetIds.length > 0 && + targetIds.every((id) => value.includes(id)); + if (allOn) { + onChange(value.filter((id) => !targetIds.includes(id))); + } else { + onChange([...new Set([...value, ...targetIds])]); + } + }; + + const summary = + selectedResourceCount === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { + count: selectedResourceCount + }); + + return ( + + + + + +
+ {rows.length === 0 ? ( +

+ {t("alertingResourcesEmpty")} +

+ ) : ( + rows.map((row) => { + const checked = + row.targetIds.length > 0 && + row.targetIds.every((id) => + value.includes(id) + ); + return ( + + ); + }) + )} +
+
+
+ ); +} + +export function ActionBlock({ + orgId, + index, + control, + form, + onRemove, + canRemove +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; + onRemove: () => void; + canRemove: boolean; +}) { + const t = useTranslations(); + const type = form.watch(`actions.${index}.type`); + return ( +
+ {canRemove && ( + + )} + ( + + {t("alertingActionType")} + + + )} + /> + {type === "notify" && ( + + )} + {type === "sms" && ( + + )} + {type === "webhook" && ( + + )} +
+ ); +} + +function NotifyActionFields({ + orgId, + index, + control, + form +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [emailActiveIdx, setEmailActiveIdx] = useState(null); + const userIds = form.watch(`actions.${index}.userIds`) ?? []; + const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; + const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; + + return ( +
+ + {t("alertingNotifyUsers")} + + form.setValue(`actions.${index}.userIds`, ids) + } + /> + + + {t("alertingNotifyRoles")} + + form.setValue(`actions.${index}.roleIds`, ids) + } + /> + + ( + + {t("alertingNotifyEmails")} + + { + const next = + typeof updater === "function" + ? updater(emailTags) + : updater; + form.setValue( + `actions.${index}.emailTags`, + next + ); + }} + activeTagIndex={emailActiveIdx} + setActiveTagIndex={setEmailActiveIdx} + placeholder={t("alertingEmailPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> +
+ ); +} + +function SmsActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); + const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; + return ( + ( + + {t("alertingSmsNumbers")} + + { + const next = + typeof updater === "function" + ? updater(phoneTags) + : updater; + form.setValue( + `actions.${index}.phoneTags`, + next + ); + }} + activeTagIndex={phoneActiveIdx} + setActiveTagIndex={setPhoneActiveIdx} + placeholder={t("alertingSmsPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> + ); +} + +function WebhookActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + return ( +
+ ( + + URL + + + + + + )} + /> + ( + + {t("alertingWebhookMethod")} + + + + )} + /> + ( + + {t("alertingWebhookSecret")} + + + + + + )} + /> + +
+ ); +} + +function WebhookHeadersField({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const headers = + form.watch(`actions.${index}.headers` as const) ?? []; + return ( +
+ {t("alertingWebhookHeaders")} + {headers.map((_, hi) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} + +
+ ); +} + +function UserMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: string[]; + onChange: (v: string[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: users = [] } = useQuery(orgQueries.users({ orgId })); + const shown = useMemo(() => { + const qq = debounced.trim().toLowerCase(); + if (!qq) return users.slice(0, 200); + return users + .filter((u) => { + const label = getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }).toLowerCase(); + return ( + label.includes(qq) || + (u.email ?? "").toLowerCase().includes(qq) + ); + }) + .slice(0, 200); + }, [users, debounced]); + const toggle = (id: string) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectUsers") + : t("alertingUsersSelected", { count: value.length }); + return ( + + + + + + + + + {t("noResults")} + + {shown.map((u) => { + const uid = String(u.id); + return ( + toggle(uid)} + className="cursor-pointer" + > + + {getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + })} + + ); + })} + + + + + + ); +} + +function RoleMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectRoles") + : t("alertingRolesSelected", { count: value.length }); + return ( + + + + + + + + + {roles.map((r) => ( + toggle(r.roleId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + +export function AlertRuleSourceFields({ + orgId, + control +}: { + orgId: string; + control: Control; +}) { + const t = useTranslations(); + const { setValue, getValues } = useFormContext(); + const sourceType = useWatch({ control, name: "sourceType" }); + return ( +
+ ( + + {t("alertingSourceType")} + + + + )} + /> + {sourceType === "site" ? ( + ( + + {t("alertingPickSites")} + + + + )} + /> + ) : ( + ( + + {t("alertingPickResources")} + + + + )} + /> + )} +
+ ); +} + +export function AlertRuleTriggerFields({ + control +}: { + control: Control; +}) { + const t = useTranslations(); + const sourceType = useWatch({ control, name: "sourceType" }); + return ( + ( + + {t("alertingTrigger")} + + + + )} + /> + ); +} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx new file mode 100644 index 000000000..78d0fc8bb --- /dev/null +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -0,0 +1,715 @@ +"use client"; + +import { + ActionBlock, + AlertRuleSourceFields, + AlertRuleTriggerFields, + DropdownAddAction +} 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 { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + 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, + formValuesToRule, + type AlertRuleFormAction, + type AlertRuleFormValues +} from "@app/lib/alertRuleForm"; +import { upsertRule } from "@app/lib/alertRulesLocalStorage"; +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 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 { useTranslations } from "next-intl"; + +type AlertRuleT = ReturnType; + +export type AlertStepId = "source" | "trigger" | "actions"; + +type AlertStepNodeData = { + roleLabel: string; + title: string; + subtitle: string; + configured: boolean; + accent: string; + topBorderClass: string; +}; + +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.targetIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummaryHealthChecks", { count: v.targetIds.length }); +} + +function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { + switch (v.trigger) { + case "site_online": + return t("alertingTriggerSiteOnline"); + case "site_offline": + return t("alertingTriggerSiteOffline"); + case "health_check_healthy": + return t("alertingTriggerHcHealthy"); + case "health_check_unhealthy": + return t("alertingTriggerHcUnhealthy"); + default: + return v.trigger; + } +} + +function oneActionConfigured(a: AlertRuleFormAction): boolean { + if (a.type === "notify") { + return ( + a.userIds.length > 0 || + a.roleIds.length > 0 || + a.emailTags.length > 0 + ); + } + if (a.type === "sms") { + return a.phoneTags.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 "sms": + return t("alertingActionSms"); + case "webhook": + return t("alertingActionWebhook"); + } +} + +function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + return t("alertingNodeNotConfigured"); + } + const parts: string[] = []; + if (a.userIds.length > 0) { + parts.push(t("alertingUsersSelected", { count: a.userIds.length })); + } + if (a.roleIds.length > 0) { + parts.push(t("alertingRolesSelected", { count: a.roleIds.length })); + } + if (a.emailTags.length > 0) { + parts.push( + `${t("alertingNotifyEmails")} (${a.emailTags.length})` + ); + } + return parts.join(" · "); + } + if (a.type === "sms") { + if (a.phoneTags.length === 0) { + return t("alertingNodeNotConfigured"); + } + return `${t("alertingSmsNumbers")}: ${a.phoneTags.length}`; + } + 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.targetIds.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; + ruleId: string; + createdAt: string; + initialValues: AlertRuleFormValues; + isNew: boolean; +}; + +const FORM_ID = "alert-rule-graph-form"; + +export default function AlertRuleGraphEditor({ + orgId, + ruleId, + createdAt, + initialValues, + isNew +}: AlertRuleGraphEditorProps) { + const t = useTranslations(); + const router = useRouter(); + const schema = useMemo(() => buildFormSchema(t), [t]); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: initialValues ?? defaultFormValues() + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + 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 wSiteIds = + useWatch({ control: form.control, name: "siteIds" }) ?? []; + const wTargetIds = + useWatch({ control: form.control, name: "targetIds" }) ?? []; + const wTrigger = + useWatch({ control: form.control, name: "trigger" }) ?? + "site_offline"; + const wActions = + useWatch({ control: form.control, name: "actions" }) ?? []; + + const flowValues: AlertRuleFormValues = useMemo( + () => ({ + name: wName, + enabled: wEnabled, + sourceType: wSourceType, + siteIds: wSiteIds, + targetIds: wTargetIds, + trigger: wTrigger, + actions: wActions + }), + [ + wName, + wEnabled, + wSourceType, + wSiteIds, + wTargetIds, + 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 = Number(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((values) => { + const next = formValuesToRule(values, ruleId, createdAt); + upsertRule(orgId, next); + toast({ title: t("alertingRuleSaved") }); + if (isNew) { + router.replace(`/${orgId}/settings/alerting/${ruleId}`); + } + }); + + 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")} + + + +
+ + + + + +
+
+
+ + + + + {sidebarTitle} + + + {t("alertingSidebarHint")} + + + +
+ {selectedStep === "source" && ( + + )} + {selectedStep === "trigger" && ( + + )} + {isActionsSidebar && ( +
+
+ + {t( + "alertingSectionActions" + )} + + { + const newIndex = + fields.length; + if (type === "notify") { + append({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } else if ( + type === "sms" + ) { + append({ + type: "sms", + phoneTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { + key: "", + value: "" + } + ], + secret: "" + }); + } + setSelectedStep( + `action-${newIndex}` + ); + }} + /> +
+ {fields.map((f, index) => ( + + remove(index) + } + canRemove + /> + ))} +
+ )} +
+
+
+
+
+
+ + ); +} diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts new file mode 100644 index 000000000..c219f316b --- /dev/null +++ b/src/lib/alertRuleForm.ts @@ -0,0 +1,283 @@ +import type { Tag } from "@app/components/tags/tag-input"; +import { + type AlertRule, + type AlertTrigger, + isoNow, + type AlertAction as StoredAlertAction +} from "@app/lib/alertRulesLocalStorage"; +import { z } from "zod"; + +export const tagSchema = z.object({ + id: z.string(), + text: z.string() +}); + +export type AlertRuleFormAction = + | { + type: "notify"; + userIds: string[]; + roleIds: number[]; + emailTags: Tag[]; + } + | { type: "sms"; phoneTags: Tag[] } + | { + type: "webhook"; + url: string; + method: string; + headers: { key: string; value: string }[]; + secret: string; + }; + +export type AlertRuleFormValues = { + name: string; + enabled: boolean; + sourceType: "site" | "health_check"; + siteIds: number[]; + targetIds: number[]; + trigger: AlertTrigger; + actions: AlertRuleFormAction[]; +}; + +export function buildFormSchema(t: (k: string) => string) { + return z + .object({ + name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + enabled: z.boolean(), + sourceType: z.enum(["site", "health_check"]), + siteIds: z.array(z.number()), + targetIds: z.array(z.number()), + trigger: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" + ]), + actions: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("sms"), + phoneTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + secret: z.string() + }) + ]) + ) + }) + .superRefine((val, ctx) => { + if (val.actions.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorActionsMin"), + path: ["actions"] + }); + } + if (val.sourceType === "site" && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickSites"), + path: ["siteIds"] + }); + } + if ( + val.sourceType === "health_check" && + val.targetIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickHealthChecks"), + path: ["targetIds"] + }); + } + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline" + ]; + const hcTriggers: AlertTrigger[] = [ + "health_check_healthy", + "health_check_unhealthy" + ]; + if ( + val.sourceType === "site" && + !siteTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerSite"), + path: ["trigger"] + }); + } + if ( + val.sourceType === "health_check" && + !hcTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerHealth"), + path: ["trigger"] + }); + } + val.actions.forEach((a, i) => { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorNotifyRecipients"), + path: ["actions", i, "userIds"] + }); + } + } + if (a.type === "sms" && a.phoneTags.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorSmsPhones"), + path: ["actions", i, "phoneTags"] + }); + } + if (a.type === "webhook") { + try { + // eslint-disable-next-line no-new + new URL(a.url.trim()); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorWebhookUrl"), + path: ["actions", i, "url"] + }); + } + } + }); + }); +} + +export function defaultFormValues(): AlertRuleFormValues { + return { + name: "", + enabled: true, + sourceType: "site", + siteIds: [], + targetIds: [], + trigger: "site_offline", + actions: [ + { + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + } + ] + }; +} + +export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { + const actions: AlertRuleFormAction[] = rule.actions.map( + (a: StoredAlertAction) => { + if (a.type === "notify") { + return { + type: "notify", + userIds: a.userIds.map(String), + roleIds: [...a.roleIds], + emailTags: a.emails.map((e) => ({ id: e, text: e })) + }; + } + if (a.type === "sms") { + return { + type: "sms", + phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) + }; + } + return { + type: "webhook", + url: a.url, + method: a.method, + headers: + a.headers.length > 0 + ? a.headers.map((h) => ({ ...h })) + : [{ key: "", value: "" }], + secret: a.secret ?? "" + }; + } + ); + return { + name: rule.name, + enabled: rule.enabled, + sourceType: rule.source.type, + siteIds: + rule.source.type === "site" ? [...rule.source.siteIds] : [], + targetIds: + rule.source.type === "health_check" + ? [...rule.source.targetIds] + : [], + trigger: rule.trigger, + actions + }; +} + +export function formValuesToRule( + v: AlertRuleFormValues, + id: string, + createdAt: string +): AlertRule { + const source = + v.sourceType === "site" + ? { type: "site" as const, siteIds: v.siteIds } + : { + type: "health_check" as const, + targetIds: v.targetIds + }; + const actions = v.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify" as const, + userIds: a.userIds, + roleIds: a.roleIds, + emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean) + }; + } + if (a.type === "sms") { + return { + type: "sms" as const, + phoneNumbers: a.phoneTags + .map((tg) => tg.text.trim()) + .filter(Boolean) + }; + } + return { + type: "webhook" as const, + url: a.url.trim(), + method: a.method, + headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), + secret: a.secret.trim() || undefined + }; + }); + return { + id, + name: v.name.trim(), + enabled: v.enabled, + createdAt, + updatedAt: isoNow(), + source, + trigger: v.trigger, + actions + }; +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts index 2e26cee71..bccc35331 100644 --- a/src/lib/alertRulesLocalStorage.ts +++ b/src/lib/alertRulesLocalStorage.ts @@ -64,6 +64,10 @@ function storageKey(orgId: string) { return `${STORAGE_PREFIX}${orgId}`; } +export function getRule(orgId: string, ruleId: string): AlertRule | undefined { + return loadRules(orgId).find((r) => r.id === ruleId); +} + export function loadRules(orgId: string): AlertRule[] { if (typeof window === "undefined") { return [];