mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 14:36:46 +00:00
add node graph and editor
This commit is contained in:
@@ -1237,6 +1237,7 @@
|
|||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
|
"search": "Search…",
|
||||||
"searchPlaceholder": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
"emptySearchOptions": "No options found",
|
"emptySearchOptions": "No options found",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@@ -1327,10 +1328,10 @@
|
|||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
"alertingTitle": "Alerting rules",
|
"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",
|
"alertingRules": "Alert rules",
|
||||||
"alertingSearchRules": "Search rules…",
|
"alertingSearchRules": "Search rules…",
|
||||||
"alertingAddRule": "Create rule",
|
"alertingAddRule": "Create Rule",
|
||||||
"alertingColumnSource": "Source",
|
"alertingColumnSource": "Source",
|
||||||
"alertingColumnTrigger": "Trigger",
|
"alertingColumnTrigger": "Trigger",
|
||||||
"alertingColumnActions": "Actions",
|
"alertingColumnActions": "Actions",
|
||||||
@@ -1350,6 +1351,10 @@
|
|||||||
"alertingSourceHealthCheck": "Health check",
|
"alertingSourceHealthCheck": "Health check",
|
||||||
"alertingPickSites": "Sites",
|
"alertingPickSites": "Sites",
|
||||||
"alertingPickHealthChecks": "Health checks",
|
"alertingPickHealthChecks": "Health checks",
|
||||||
|
"alertingPickResources": "Resources",
|
||||||
|
"alertingSelectResources": "Select resources…",
|
||||||
|
"alertingResourcesSelected": "{count} resources selected",
|
||||||
|
"alertingResourcesEmpty": "No resources with targets in the first 10 results.",
|
||||||
"alertingSectionTrigger": "Trigger",
|
"alertingSectionTrigger": "Trigger",
|
||||||
"alertingTrigger": "When to alert",
|
"alertingTrigger": "When to alert",
|
||||||
"alertingTriggerSiteOnline": "Site online",
|
"alertingTriggerSiteOnline": "Site online",
|
||||||
@@ -1378,6 +1383,7 @@
|
|||||||
"alertingSelectHealthChecks": "Select health checks…",
|
"alertingSelectHealthChecks": "Select health checks…",
|
||||||
"alertingHealthChecksSelected": "{count} health checks selected",
|
"alertingHealthChecksSelected": "{count} health checks selected",
|
||||||
"alertingNoHealthChecks": "No targets with health checks enabled",
|
"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…",
|
"alertingSelectUsers": "Select users…",
|
||||||
"alertingUsersSelected": "{count} users selected",
|
"alertingUsersSelected": "{count} users selected",
|
||||||
"alertingSelectRoles": "Select roles…",
|
"alertingSelectRoles": "Select roles…",
|
||||||
@@ -1393,6 +1399,19 @@
|
|||||||
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
|
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
|
||||||
"alertingErrorSmsPhones": "Add at least one phone number",
|
"alertingErrorSmsPhones": "Add at least one phone number",
|
||||||
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
|
"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",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
|
|||||||
130
package-lock.json
generated
130
package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xyflow/react": "^12.8.4",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -1058,6 +1059,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -2353,6 +2355,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2375,6 +2378,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2397,6 +2401,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2413,6 +2418,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2429,6 +2435,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2445,6 +2452,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2461,6 +2469,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2477,6 +2486,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2493,6 +2503,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2509,6 +2520,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2525,6 +2537,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2541,6 +2554,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2563,6 +2577,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2585,6 +2600,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2607,6 +2623,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2629,6 +2646,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2651,6 +2669,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2673,6 +2692,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2695,6 +2715,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2714,6 +2735,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2733,6 +2755,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2752,6 +2775,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3002,6 +3026,7 @@
|
|||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -6948,6 +6973,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -8408,6 +8434,7 @@
|
|||||||
"version": "5.90.21",
|
"version": "5.90.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.90.20"
|
||||||
},
|
},
|
||||||
@@ -8523,6 +8550,7 @@
|
|||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -8682,7 +8710,6 @@
|
|||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
@@ -8798,7 +8825,6 @@
|
|||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
"node_modules/@types/d3-shape": {
|
||||||
@@ -8833,7 +8859,6 @@
|
|||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
@@ -8843,7 +8868,6 @@
|
|||||||
"version": "3.0.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-interpolate": "*",
|
"@types/d3-interpolate": "*",
|
||||||
@@ -8870,6 +8894,7 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -8965,6 +8990,7 @@
|
|||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -8992,6 +9018,7 @@
|
|||||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"pg-protocol": "*",
|
"pg-protocol": "*",
|
||||||
@@ -9017,6 +9044,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -9027,6 +9055,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -9113,8 +9142,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
@@ -9188,6 +9216,7 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -9642,6 +9671,38 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -9661,6 +9722,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -10109,6 +10171,7 @@
|
|||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -10180,6 +10243,7 @@
|
|||||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
@@ -10308,6 +10372,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -10490,6 +10555,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/cli-spinners": {
|
||||||
"version": "2.9.2",
|
"version": "2.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -11654,7 +11726,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -12289,6 +12360,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -12374,6 +12446,7 @@
|
|||||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -12510,6 +12583,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -12903,6 +12977,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -15320,7 +15395,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.2.7",
|
||||||
"marked": "14.0.0"
|
"marked": "14.0.0"
|
||||||
@@ -15331,7 +15405,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
@@ -15419,6 +15492,7 @@
|
|||||||
"version": "15.5.12",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
||||||
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.12",
|
"@next/env": "15.5.12",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -16377,6 +16451,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@@ -16881,6 +16956,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -16912,6 +16988,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -17204,6 +17281,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -18665,7 +18743,8 @@
|
|||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||||
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -19140,6 +19219,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -19567,6 +19647,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
@@ -19773,6 +19854,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -19788,6 +19870,34 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xyflow/react": "^12.8.4",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
@@ -340,15 +340,16 @@ export default function Page() {
|
|||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api.post<AxiosResponse<InviteUserResponse>>(
|
const res = await api
|
||||||
`/org/${orgId}/create-invite`,
|
.post<AxiosResponse<InviteUserResponse>>(
|
||||||
{
|
`/org/${orgId}/create-invite`,
|
||||||
email: values.email,
|
{
|
||||||
roleIds,
|
email: values.email,
|
||||||
validHours: parseInt(values.validForHours),
|
roleIds,
|
||||||
sendEmail
|
validHours: parseInt(values.validForHours),
|
||||||
}
|
sendEmail
|
||||||
)
|
}
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e.response?.status === 409) {
|
if (e.response?.status === 409) {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
52
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
52
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
@@ -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<AlertRule | null | undefined>(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 (
|
||||||
|
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertRuleGraphEditor
|
||||||
|
key={rule.id}
|
||||||
|
orgId={orgId}
|
||||||
|
ruleId={rule.id}
|
||||||
|
createdAt={rule.createdAt}
|
||||||
|
initialValues={ruleToFormValues(rule)}
|
||||||
|
isNew={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
40
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertRuleGraphEditor
|
||||||
|
key={meta.id}
|
||||||
|
orgId={orgId}
|
||||||
|
ruleId={meta.id}
|
||||||
|
createdAt={meta.createdAt}
|
||||||
|
initialValues={defaultFormValues()}
|
||||||
|
isNew
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import AlertRuleCredenza from "@app/components/AlertRuleCredenza";
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
DataTable,
|
|
||||||
ExtendedColumnDef
|
|
||||||
} from "@app/components/ui/data-table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -24,6 +20,8 @@ import {
|
|||||||
} from "@app/lib/alertRulesLocalStorage";
|
} from "@app/lib/alertRulesLocalStorage";
|
||||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
@@ -32,7 +30,14 @@ type AlertingRulesTableProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sourceSummary(rule: AlertRule, t: (k: string, o?: Record<string, number | string>) => string) {
|
function ruleHref(orgId: string, ruleId: string) {
|
||||||
|
return `/${orgId}/settings/alerting/${ruleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceSummary(
|
||||||
|
rule: AlertRule,
|
||||||
|
t: (k: string, o?: Record<string, number | string>) => string
|
||||||
|
) {
|
||||||
if (rule.source.type === "site") {
|
if (rule.source.type === "site") {
|
||||||
return t("alertingSummarySites", {
|
return t("alertingSummarySites", {
|
||||||
count: rule.source.siteIds.length
|
count: rule.source.siteIds.length
|
||||||
@@ -83,10 +88,9 @@ function actionBadges(rule: AlertRule, t: (k: string) => string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [rows, setRows] = useState<AlertRule[]>([]);
|
const [rows, setRows] = useState<AlertRule[]>([]);
|
||||||
const [credenzaOpen, setCredenzaOpen] = useState(false);
|
|
||||||
const [credenzaRule, setCredenzaRule] = useState<AlertRule | null>(null);
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [selected, setSelected] = useState<AlertRule | null>(null);
|
const [selected, setSelected] = useState<AlertRule | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
@@ -146,10 +150,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
{
|
{
|
||||||
id: "source",
|
id: "source",
|
||||||
friendlyName: t("alertingColumnSource"),
|
friendlyName: t("alertingColumnSource"),
|
||||||
header: () => <span className="p-3">{t("alertingColumnSource")}</span>,
|
header: () => (
|
||||||
cell: ({ row }) => (
|
<span className="p-3">{t("alertingColumnSource")}</span>
|
||||||
<span>{sourceSummary(row.original, t)}</span>
|
),
|
||||||
)
|
cell: ({ row }) => <span>{sourceSummary(row.original, t)}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "trigger",
|
id: "trigger",
|
||||||
@@ -190,9 +194,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
friendlyName: t("createdAt"),
|
friendlyName: t("createdAt"),
|
||||||
header: () => (
|
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||||
<span className="p-3">{t("createdAt")}</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>{moment(row.original.createdAt).format("lll")}</span>
|
<span>{moment(row.original.createdAt).format("lll")}</span>
|
||||||
)
|
)
|
||||||
@@ -204,13 +206,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t("openMenu")}
|
{t("openMenu")}
|
||||||
</span>
|
</span>
|
||||||
@@ -218,14 +217,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setCredenzaRule(r);
|
|
||||||
setCredenzaOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
@@ -238,6 +229,11 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={ruleHref(orgId, r.id)}>
|
||||||
|
{t("edit")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -246,16 +242,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AlertRuleCredenza
|
|
||||||
open={credenzaOpen}
|
|
||||||
setOpen={(v) => {
|
|
||||||
setCredenzaOpen(v);
|
|
||||||
if (!v) setCredenzaRule(null);
|
|
||||||
}}
|
|
||||||
orgId={orgId}
|
|
||||||
rule={credenzaRule}
|
|
||||||
onSaved={refreshFromStorage}
|
|
||||||
/>
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
@@ -282,8 +268,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
|||||||
searchPlaceholder={t("alertingSearchRules")}
|
searchPlaceholder={t("alertingSearchRules")}
|
||||||
searchColumn="name"
|
searchColumn="name"
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
setCredenzaRule(null);
|
router.push(`/${orgId}/settings/alerting/create`);
|
||||||
setCredenzaOpen(true);
|
|
||||||
}}
|
}}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
|
|||||||
981
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
981
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
@@ -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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t("alertingAddAction")}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-52 p-2" align="end">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => {
|
||||||
|
onPick("notify");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("alertingActionNotify")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => {
|
||||||
|
onPick("sms");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("alertingActionSms")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => {
|
||||||
|
onPick("webhook");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("alertingActionWebhook")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">{summary}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("siteSearch")}
|
||||||
|
value={q}
|
||||||
|
onValueChange={setQ}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.siteId}
|
||||||
|
value={`${s.siteId}`}
|
||||||
|
onSelect={() => toggle(s.siteId)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.includes(s.siteId)}
|
||||||
|
className="mr-2 pointer-events-none"
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">{summary}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="max-h-72 overflow-y-auto p-2 space-y-0.5">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground px-2 py-1.5">
|
||||||
|
{t("alertingResourcesEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => {
|
||||||
|
const checked =
|
||||||
|
row.targetIds.length > 0 &&
|
||||||
|
row.targetIds.every((id) =>
|
||||||
|
value.includes(id)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={row.resourceId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(row.targetIds)}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
className="pointer-events-none shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionBlock({
|
||||||
|
orgId,
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
form,
|
||||||
|
onRemove,
|
||||||
|
canRemove
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
index: number;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
form: UseFormReturn<AlertRuleFormValues>;
|
||||||
|
onRemove: () => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const type = form.watch(`actions.${index}.type`);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-4 space-y-3 relative">
|
||||||
|
{canRemove && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.type`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingActionType")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const nt = v as AlertRuleFormAction["type"];
|
||||||
|
if (nt === "notify") {
|
||||||
|
form.setValue(`actions.${index}`, {
|
||||||
|
type: "notify",
|
||||||
|
userIds: [],
|
||||||
|
roleIds: [],
|
||||||
|
emailTags: []
|
||||||
|
});
|
||||||
|
} else if (nt === "sms") {
|
||||||
|
form.setValue(`actions.${index}`, {
|
||||||
|
type: "sms",
|
||||||
|
phoneTags: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setValue(`actions.${index}`, {
|
||||||
|
type: "webhook",
|
||||||
|
url: "",
|
||||||
|
method: "POST",
|
||||||
|
headers: [{ key: "", value: "" }],
|
||||||
|
secret: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="max-w-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="notify">
|
||||||
|
{t("alertingActionNotify")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="sms">
|
||||||
|
{t("alertingActionSms")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="webhook">
|
||||||
|
{t("alertingActionWebhook")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{type === "notify" && (
|
||||||
|
<NotifyActionFields
|
||||||
|
orgId={orgId}
|
||||||
|
index={index}
|
||||||
|
control={control}
|
||||||
|
form={form}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === "sms" && (
|
||||||
|
<SmsActionFields index={index} control={control} form={form} />
|
||||||
|
)}
|
||||||
|
{type === "webhook" && (
|
||||||
|
<WebhookActionFields
|
||||||
|
index={index}
|
||||||
|
control={control}
|
||||||
|
form={form}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifyActionFields({
|
||||||
|
orgId,
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
index: number;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
form: UseFormReturn<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
||||||
|
const userIds = form.watch(`actions.${index}.userIds`) ?? [];
|
||||||
|
const roleIds = form.watch(`actions.${index}.roleIds`) ?? [];
|
||||||
|
const emailTags = form.watch(`actions.${index}.emailTags`) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||||
|
<UserMultiSelect
|
||||||
|
orgId={orgId}
|
||||||
|
value={userIds}
|
||||||
|
onChange={(ids) =>
|
||||||
|
form.setValue(`actions.${index}.userIds`, ids)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||||
|
<RoleMultiSelect
|
||||||
|
orgId={orgId}
|
||||||
|
value={roleIds}
|
||||||
|
onChange={(ids) =>
|
||||||
|
form.setValue(`actions.${index}.roleIds`, ids)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.emailTags`}
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingNotifyEmails")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
tags={emailTags}
|
||||||
|
setTags={(updater) => {
|
||||||
|
const next =
|
||||||
|
typeof updater === "function"
|
||||||
|
? updater(emailTags)
|
||||||
|
: updater;
|
||||||
|
form.setValue(
|
||||||
|
`actions.${index}.emailTags`,
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
activeTagIndex={emailActiveIdx}
|
||||||
|
setActiveTagIndex={setEmailActiveIdx}
|
||||||
|
placeholder={t("alertingEmailPlaceholder")}
|
||||||
|
delimiterList={[",", "Enter"]}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmsActionFields({
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
form: UseFormReturn<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [phoneActiveIdx, setPhoneActiveIdx] = useState<number | null>(null);
|
||||||
|
const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? [];
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.phoneTags`}
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingSmsNumbers")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
tags={phoneTags}
|
||||||
|
setTags={(updater) => {
|
||||||
|
const next =
|
||||||
|
typeof updater === "function"
|
||||||
|
? updater(phoneTags)
|
||||||
|
: updater;
|
||||||
|
form.setValue(
|
||||||
|
`actions.${index}.phoneTags`,
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
activeTagIndex={phoneActiveIdx}
|
||||||
|
setActiveTagIndex={setPhoneActiveIdx}
|
||||||
|
placeholder={t("alertingSmsPlaceholder")}
|
||||||
|
delimiterList={[",", "Enter"]}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookActionFields({
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
form: UseFormReturn<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.url`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="https://example.com/hook"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.method`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingWebhookMethod")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="max-w-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{["GET", "POST", "PUT", "PATCH"].map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.secret`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingWebhookSecret")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={t(
|
||||||
|
"alertingWebhookSecretPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<WebhookHeadersField index={index} control={control} form={form} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookHeadersField({
|
||||||
|
index,
|
||||||
|
control,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
form: UseFormReturn<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const headers =
|
||||||
|
form.watch(`actions.${index}.headers` as const) ?? [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>{t("alertingWebhookHeaders")}</FormLabel>
|
||||||
|
{headers.map((_, hi) => (
|
||||||
|
<div key={hi} className="flex gap-2 items-start">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.headers.${hi}.key`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Key" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.headers.${hi}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Value" />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
const cur =
|
||||||
|
form.getValues(
|
||||||
|
`actions.${index}.headers`
|
||||||
|
) ?? [];
|
||||||
|
form.setValue(
|
||||||
|
`actions.${index}.headers`,
|
||||||
|
cur.filter((__, i) => i !== hi)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const cur =
|
||||||
|
form.getValues(`actions.${index}.headers`) ?? [];
|
||||||
|
form.setValue(`actions.${index}.headers`, [
|
||||||
|
...cur,
|
||||||
|
{ key: "", value: "" }
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t("alertingAddHeader")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">{summary}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
value={q}
|
||||||
|
onValueChange={setQ}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{shown.map((u) => {
|
||||||
|
const uid = String(u.id);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={uid}
|
||||||
|
value={uid}
|
||||||
|
onSelect={() => toggle(uid)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.includes(uid)}
|
||||||
|
className="mr-2 pointer-events-none"
|
||||||
|
/>
|
||||||
|
{getUserDisplayName({
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
username: u.username
|
||||||
|
})}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">{summary}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<CommandItem
|
||||||
|
key={r.roleId}
|
||||||
|
value={`role-${r.roleId}`}
|
||||||
|
onSelect={() => toggle(r.roleId)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.includes(r.roleId)}
|
||||||
|
className="mr-2 pointer-events-none"
|
||||||
|
/>
|
||||||
|
{r.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertRuleSourceFields({
|
||||||
|
orgId,
|
||||||
|
control
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { setValue, getValues } = useFormContext<AlertRuleFormValues>();
|
||||||
|
const sourceType = useWatch({ control, name: "sourceType" });
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="sourceType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingSourceType")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const next = v as AlertRuleFormValues["sourceType"];
|
||||||
|
field.onChange(next);
|
||||||
|
const curTrigger = getValues("trigger");
|
||||||
|
if (next === "site") {
|
||||||
|
if (
|
||||||
|
curTrigger !== "site_online" &&
|
||||||
|
curTrigger !== "site_offline"
|
||||||
|
) {
|
||||||
|
setValue("trigger", "site_offline", {
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
curTrigger !== "health_check_healthy" &&
|
||||||
|
curTrigger !== "health_check_unhealthy"
|
||||||
|
) {
|
||||||
|
setValue(
|
||||||
|
"trigger",
|
||||||
|
"health_check_unhealthy",
|
||||||
|
{ shouldValidate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="site">
|
||||||
|
{t("alertingSourceSite")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="health_check">
|
||||||
|
{t("alertingSourceHealthCheck")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{sourceType === "site" ? (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="siteIds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingPickSites")}</FormLabel>
|
||||||
|
<SiteMultiSelect
|
||||||
|
orgId={orgId}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="targetIds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingPickResources")}</FormLabel>
|
||||||
|
<ResourceTenMultiSelect
|
||||||
|
orgId={orgId}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertRuleTriggerFields({
|
||||||
|
control
|
||||||
|
}: {
|
||||||
|
control: Control<AlertRuleFormValues>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const sourceType = useWatch({ control, name: "sourceType" });
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="trigger"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("alertingTrigger")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
key={sourceType}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceType === "site" ? (
|
||||||
|
<>
|
||||||
|
<SelectItem value="site_online">
|
||||||
|
{t("alertingTriggerSiteOnline")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="site_offline">
|
||||||
|
{t("alertingTriggerSiteOffline")}
|
||||||
|
</SelectItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SelectItem value="health_check_healthy">
|
||||||
|
{t("alertingTriggerHcHealthy")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="health_check_unhealthy">
|
||||||
|
{t("alertingTriggerHcUnhealthy")}
|
||||||
|
</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
715
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
715
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
@@ -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<typeof useTranslations>;
|
||||||
|
|
||||||
|
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<Node<AlertStepNodeData>>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-xl border-2 border-t-[3px] bg-card px-5 py-4 shadow-sm min-w-[260px] max-w-[320px] transition-shadow",
|
||||||
|
data.topBorderClass,
|
||||||
|
selected
|
||||||
|
? "border-primary ring-2 ring-primary/25 shadow-md"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!bg-muted-foreground !w-2 !h-2"
|
||||||
|
/>
|
||||||
|
{data.configured && (
|
||||||
|
<Check
|
||||||
|
className="absolute top-3 right-3 h-5 w-5 text-green-600"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-semibold uppercase tracking-wide",
|
||||||
|
data.accent
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.roleLabel}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-base mt-1">{data.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-3 leading-snug">
|
||||||
|
{data.subtitle}
|
||||||
|
</p>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="!bg-muted-foreground !w-2 !h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<AlertRuleFormValues>({
|
||||||
|
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<string>("source");
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||||
|
<SettingsContainer>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 sm:p-5 space-y-4">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:flex-wrap md:items-center">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/${orgId}/settings/alerting`}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
{t("alertingBackToRules")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{isNew && (
|
||||||
|
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
|
||||||
|
{t("alertingDraftBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1 min-w-0 md:min-w-[12rem] md:max-w-md">
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"alertingRuleNamePlaceholder"
|
||||||
|
)}
|
||||||
|
className="font-medium"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 md:ml-auto">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormLabel className="text-sm font-normal cursor-pointer whitespace-nowrap">
|
||||||
|
{t("alertingRuleEnabled")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||||
|
<Card className="flex flex-col w-full overflow-hidden">
|
||||||
|
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
||||||
|
<CardTitle className="text-lg font-bold tracking-tight">
|
||||||
|
{t("alertingGraphCanvasTitle")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="pt-0">
|
||||||
|
{t("alertingGraphCanvasDescription")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border bg-muted/30 overflow-hidden",
|
||||||
|
"min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{
|
||||||
|
padding: 0.35
|
||||||
|
}}
|
||||||
|
minZoom={0.5}
|
||||||
|
maxZoom={1.25}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable
|
||||||
|
panOnScroll
|
||||||
|
zoomOnScroll
|
||||||
|
proOptions={{
|
||||||
|
hideAttribution: true
|
||||||
|
}}
|
||||||
|
className="bg-transparent !h-full !w-full min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
||||||
|
>
|
||||||
|
<Background gap={16} size={1} />
|
||||||
|
</ReactFlow>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col w-full">
|
||||||
|
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
||||||
|
<CardTitle className="text-lg font-bold tracking-tight">
|
||||||
|
{sidebarTitle}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="pt-0">
|
||||||
|
{t("alertingSidebarHint")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{selectedStep === "source" && (
|
||||||
|
<AlertRuleSourceFields
|
||||||
|
orgId={orgId}
|
||||||
|
control={form.control}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedStep === "trigger" && (
|
||||||
|
<AlertRuleTriggerFields
|
||||||
|
control={form.control}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isActionsSidebar && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t(
|
||||||
|
"alertingSectionActions"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<DropdownAddAction
|
||||||
|
onPick={(type) => {
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{fields.map((f, index) => (
|
||||||
|
<ActionBlock
|
||||||
|
key={f.id}
|
||||||
|
orgId={orgId}
|
||||||
|
index={index}
|
||||||
|
control={form.control}
|
||||||
|
form={form}
|
||||||
|
onRemove={() =>
|
||||||
|
remove(index)
|
||||||
|
}
|
||||||
|
canRemove
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</SettingsContainer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
src/lib/alertRuleForm.ts
Normal file
283
src/lib/alertRuleForm.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ function storageKey(orgId: string) {
|
|||||||
return `${STORAGE_PREFIX}${orgId}`;
|
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[] {
|
export function loadRules(orgId: string): AlertRule[] {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user