Compare commits

...

10 Commits

Author SHA1 Message Date
Owen
7d50703c26 First pass 2026-04-14 21:58:36 -07:00
Owen
33182bcf85 Add init alert schema 2026-04-14 21:43:16 -07:00
Owen
fb29efeff3 Merge branch 'dev' into alerting-rules 2026-04-14 21:10:10 -07:00
Owen
ae36d3228f Remove journal 2026-04-14 20:23:56 -07:00
Owen
1c78a6b483 Adjust self serve 2026-04-14 20:21:34 -07:00
Owen
af709331fb Add missing DnsRecords type 2026-04-14 19:46:25 -07:00
Owen
e20a21bacd Contact support 2026-04-14 19:46:19 -07:00
Owen
74b3b283f7 Fix #2848 2026-04-13 21:30:19 -07:00
miloschwartz
4cce6e0820 add node graph and editor 2026-03-29 20:25:17 -07:00
miloschwartz
2841c5ed4e basic rules 2026-03-29 14:19:26 -07:00
30 changed files with 3851 additions and 201 deletions

1
config/db/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*-journal

View File

@@ -1256,6 +1256,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",
@@ -1340,10 +1341,96 @@
"sidebarGeneral": "Manage", "sidebarGeneral": "Manage",
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarAlerting": "Alerting",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarManagement": "Management", "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses", "sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics", "sidebarLogsAnalytics": "Analytics",
"alertingTitle": "Alerting rules",
"alertingDescription": "Define sources, triggers, and actions for notifications.",
"alertingRules": "Alert rules",
"alertingSearchRules": "Search rules…",
"alertingAddRule": "Create Rule",
"alertingColumnSource": "Source",
"alertingColumnTrigger": "Trigger",
"alertingColumnActions": "Actions",
"alertingColumnEnabled": "Enabled",
"alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.",
"alertingDeleteRule": "Delete alert rule",
"alertingRuleDeleted": "Alert rule deleted",
"alertingRuleSaved": "Alert rule saved",
"alertingEditRule": "Edit alert rule",
"alertingCreateRule": "Create alert rule",
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
"alertingRuleNamePlaceholder": "Production site down",
"alertingRuleEnabled": "Rule enabled",
"alertingSectionSource": "Source",
"alertingSourceType": "Source type",
"alertingSourceSite": "Site",
"alertingSourceHealthCheck": "Health check",
"alertingPickSites": "Sites",
"alertingPickHealthChecks": "Health checks",
"alertingPickResources": "Resources",
"alertingSelectResources": "Select resources…",
"alertingResourcesSelected": "{count} resources selected",
"alertingResourcesEmpty": "No resources with targets in the first 10 results.",
"alertingSectionTrigger": "Trigger",
"alertingTrigger": "When to alert",
"alertingTriggerSiteOnline": "Site online",
"alertingTriggerSiteOffline": "Site offline",
"alertingTriggerHcHealthy": "Health check healthy",
"alertingTriggerHcUnhealthy": "Health check unhealthy",
"alertingSectionActions": "Actions",
"alertingAddAction": "Add action",
"alertingActionNotify": "Notify",
"alertingActionSms": "SMS",
"alertingActionWebhook": "Webhook",
"alertingActionType": "Action type",
"alertingNotifyUsers": "Users",
"alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email addresses",
"alertingEmailPlaceholder": "Add email and press Enter",
"alertingSmsNumbers": "Phone numbers",
"alertingSmsPlaceholder": "Add number and press Enter",
"alertingWebhookMethod": "HTTP method",
"alertingWebhookSecret": "Signing secret (optional)",
"alertingWebhookSecretPlaceholder": "HMAC secret",
"alertingWebhookHeaders": "Headers",
"alertingAddHeader": "Add header",
"alertingSelectSites": "Select sites…",
"alertingSitesSelected": "{count} sites selected",
"alertingSelectHealthChecks": "Select health checks…",
"alertingHealthChecksSelected": "{count} health checks selected",
"alertingNoHealthChecks": "No targets with health checks enabled",
"alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.",
"alertingSelectUsers": "Select users…",
"alertingUsersSelected": "{count} users selected",
"alertingSelectRoles": "Select roles…",
"alertingRolesSelected": "{count} roles selected",
"alertingSummarySites": "Sites ({count})",
"alertingSummaryHealthChecks": "Health checks ({count})",
"alertingErrorNameRequired": "Enter a name",
"alertingErrorActionsMin": "Add at least one action",
"alertingErrorPickSites": "Select at least one site",
"alertingErrorPickHealthChecks": "Select at least one health check",
"alertingErrorTriggerSite": "Choose a site trigger",
"alertingErrorTriggerHealth": "Choose a health check trigger",
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
"alertingErrorSmsPhones": "Add at least one phone number",
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
"alertingConfigureSource": "Configure Source",
"alertingConfigureTrigger": "Configure Trigger",
"alertingConfigureActions": "Configure Actions",
"alertingBackToRules": "Back to Rules",
"alertingDraftBadge": "Draft — save to store this rule",
"alertingSidebarHint": "Click a step on the canvas to edit it here.",
"alertingGraphCanvasTitle": "Rule Flow",
"alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.",
"alertingNodeNotConfigured": "Not configured yet",
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
"alertingNodeRoleSource": "Source",
"alertingNodeRoleTrigger": "Trigger",
"alertingNodeRoleAction": "Action",
"blueprints": "Blueprints", "blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintAdd": "Add Blueprint", "blueprintAdd": "Add Blueprint",
@@ -2351,7 +2438,7 @@
}, },
"scale": { "scale": {
"title": "Scale", "title": "Scale",
"description": "Enterprise features, 50 users, 50 sites, and priority support." "description": "Enterprise features, 50 users, 100 sites, and priority support."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Personal use only (free license - no checkout)",
@@ -2824,9 +2911,9 @@
"streamingHttpWebhookTitle": "HTTP Webhook", "streamingHttpWebhookTitle": "HTTP Webhook",
"streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.", "streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.",
"streamingS3Title": "Amazon S3", "streamingS3Title": "Amazon S3",
"streamingS3Description": "Stream events to an S3-compatible object storage bucket. Coming soon.", "streamingS3Description": "Stream events to an S3-compatible object storage bucket. Contact support to enable this destination.",
"streamingDatadogTitle": "Datadog", "streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Forward events directly to your Datadog account. Coming soon.", "streamingDatadogDescription": "Forward events directly to your Datadog account. Contact support to enable this destination.",
"streamingTypePickerDescription": "Choose a destination type to get started.", "streamingTypePickerDescription": "Choose a destination type to get started.",
"streamingFailedToLoad": "Failed to load destinations", "streamingFailedToLoad": "Failed to load destinations",
"streamingUnexpectedError": "An unexpected error occurred.", "streamingUnexpectedError": "An unexpected error occurred.",
@@ -2849,7 +2936,7 @@
"httpDestNamePlaceholder": "My HTTP destination", "httpDestNamePlaceholder": "My HTTP destination",
"httpDestUrlLabel": "Destination URL", "httpDestUrlLabel": "Destination URL",
"httpDestUrlErrorHttpRequired": "URL must use http or https", "httpDestUrlErrorHttpRequired": "URL must use http or https",
"httpDestUrlErrorHttpsRequired": "HTTPS is required on cloud deployments", "httpDestUrlErrorHttpsRequired": "HTTPS is required",
"httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)", "httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)",
"httpDestAuthTitle": "Authentication", "httpDestAuthTitle": "Authentication",
"httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.", "httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.",

136
package-lock.json generated
View File

@@ -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",
@@ -89,13 +90,13 @@
"reodotdev": "1.1.0", "reodotdev": "1.1.0",
"resend": "6.9.2", "resend": "6.9.2",
"semver": "7.7.4", "semver": "7.7.4",
"sshpk": "^1.18.0", "sshpk": "1.18.0",
"stripe": "20.4.1", "stripe": "20.4.1",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "^10.1.0", "use-debounce": "10.1.0",
"uuid": "13.0.0", "uuid": "13.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
@@ -130,7 +131,7 @@
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/sshpk": "^1.17.4", "@types/sshpk": "1.17.4",
"@types/swagger-ui-express": "4.1.8", "@types/swagger-ui-express": "4.1.8",
"@types/topojson-client": "3.1.5", "@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1", "@types/ws": "8.18.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": [
@@ -3011,6 +3035,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"
}, },
@@ -6957,6 +6982,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"
}, },
@@ -8417,6 +8443,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"
}, },
@@ -8532,6 +8559,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": "*"
} }
@@ -8691,7 +8719,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": "*"
@@ -8807,7 +8834,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": {
@@ -8842,7 +8868,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": "*"
@@ -8852,7 +8877,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": "*",
@@ -8879,6 +8903,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",
@@ -8974,6 +8999,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"
} }
@@ -9001,6 +9027,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": "*",
@@ -9026,6 +9053,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"
} }
@@ -9036,6 +9064,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"
} }
@@ -9122,8 +9151,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",
@@ -9197,6 +9225,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",
@@ -9651,6 +9680,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",
@@ -9670,6 +9731,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"
}, },
@@ -10118,6 +10180,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"
} }
@@ -10189,6 +10252,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"
@@ -10317,6 +10381,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",
@@ -10499,6 +10564,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",
@@ -11223,6 +11294,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"
} }
@@ -11663,7 +11735,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"
}, },
@@ -12298,6 +12369,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -12383,6 +12455,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",
@@ -12519,6 +12592,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",
@@ -12912,6 +12986,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",
@@ -15329,7 +15404,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"
@@ -15340,7 +15414,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"
}, },
@@ -15429,6 +15502,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==", "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.5.14", "@next/env": "15.5.14",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
@@ -16388,6 +16462,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",
@@ -16892,6 +16967,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"
} }
@@ -16923,6 +16999,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"
}, },
@@ -17215,6 +17292,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"
}, },
@@ -18676,7 +18754,8 @@
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.2", "version": "2.3.2",
@@ -19151,6 +19230,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"
@@ -19578,6 +19658,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",
@@ -19784,6 +19865,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"
} }
@@ -19799,6 +19881,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
}
}
} }
} }
} }

View File

@@ -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",

View File

@@ -16,11 +16,13 @@ import {
domains, domains,
orgs, orgs,
targets, targets,
roles,
users, users,
exitNodes, exitNodes,
sessions, sessions,
clients, clients,
siteResources, siteResources,
targetHealthCheck,
sites sites
} from "./schema"; } from "./schema";
@@ -425,7 +427,9 @@ export const eventStreamingDestinations = pgTable(
orgId: varchar("orgId", { length: 255 }) orgId: varchar("orgId", { length: 255 })
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), sendConnectionLogs: boolean("sendConnectionLogs")
.notNull()
.default(false),
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
sendActionLogs: boolean("sendActionLogs").notNull().default(false), sendActionLogs: boolean("sendActionLogs").notNull().default(false),
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
@@ -447,7 +451,9 @@ export const eventStreamingCursors = pgTable(
onDelete: "cascade" onDelete: "cascade"
}), }),
logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection" logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection"
lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0), lastSentId: bigint("lastSentId", { mode: "number" })
.notNull()
.default(0),
lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent
}, },
(table) => [ (table) => [
@@ -458,6 +464,66 @@ export const eventStreamingCursors = pgTable(
] ]
); );
export const alertRules = pgTable("alertRules", {
alertRuleId: serial("alertRuleId").primaryKey(),
orgId: varchar("orgId", { length: 255 })
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// Single field encodes both source and trigger - no redundancy
eventType: varchar("eventType", { length: 100 })
.$type<
| "site_online"
| "site_offline"
| "health_check_healthy"
| "health_check_not_healthy"
>()
.notNull(),
// Nullable depending on eventType
siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }),
healthCheckId: integer("healthCheckId").references(
() => targetHealthCheck.targetHealthCheckId,
{ onDelete: "cascade" }
),
enabled: boolean("enabled").notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
});
// Separating channels by type avoids the mixed-shape problem entirely
export const alertEmailActions = pgTable("alertEmailActions", {
emailActionId: serial("emailActionId").primaryKey(),
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable
});
export const alertEmailRecipients = pgTable("alertEmailRecipients", {
recipientId: serial("recipientId").primaryKey(),
emailActionId: integer("emailActionId")
.notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
// At least one of these should be set - enforced at app level
userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }),
roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
email: varchar("email", { length: 255 }) // external emails not tied to a user
});
export const alertWebhookActions = pgTable("alertWebhookActions", {
webhookActionId: serial("webhookActionId").primaryKey(),
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
webhookUrl: text("webhookUrl").notNull(),
config: text("config"), // encrypted JSON with auth config (authType, credentials)
enabled: boolean("enabled").notNull().default(true),
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;

View File

@@ -1080,6 +1080,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>; export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>; export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>; export type Domain = InferSelectModel<typeof domains>;
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
export type SupporterKey = InferSelectModel<typeof supporterKey>; export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>; export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>; export type ApiKey = InferSelectModel<typeof apiKeys>;

View File

@@ -13,9 +13,11 @@ import {
domains, domains,
exitNodes, exitNodes,
orgs, orgs,
roles,
sessions, sessions,
siteResources, siteResources,
sites, sites,
targetHealthCheck,
users users
} from "./schema"; } from "./schema";
@@ -455,6 +457,62 @@ export const eventStreamingCursors = sqliteTable(
] ]
); );
export const alertRules = sqliteTable("alertRules", {
alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
eventType: text("eventType")
.$type<
| "site_online"
| "site_offline"
| "health_check_healthy"
| "health_check_not_healthy"
>()
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }),
healthCheckId: integer("healthCheckId").references(
() => targetHealthCheck.targetHealthCheckId,
{ onDelete: "cascade" }
),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
});
export const alertEmailActions = sqliteTable("alertEmailActions", {
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSentAt: integer("lastSentAt")
});
export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
emailActionId: integer("emailActionId")
.notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
email: text("email")
});
export const alertWebhookActions = sqliteTable("alertWebhookActions", {
webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
webhookUrl: text("webhookUrl").notNull(),
config: text("config"), // encrypted JSON with auth config (authType, credentials)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSentAt: integer("lastSentAt")
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;

View File

@@ -0,0 +1,140 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailInfoSection,
EmailLetterHead,
EmailSignature,
EmailText
} from "./components/Email";
export type AlertEventType =
| "site_online"
| "site_offline"
| "health_check_healthy"
| "health_check_not_healthy";
interface Props {
eventType: AlertEventType;
orgId: string;
data: Record<string, unknown>;
}
function getEventMeta(eventType: AlertEventType): {
heading: string;
previewText: string;
summary: string;
statusLabel: string;
statusColor: string;
} {
switch (eventType) {
case "site_online":
return {
heading: "Site Back Online",
previewText: "A site in your organization is back online.",
summary:
"Good news a site in your organization has come back online and is now reachable.",
statusLabel: "Online",
statusColor: "#16a34a"
};
case "site_offline":
return {
heading: "Site Offline",
previewText: "A site in your organization has gone offline.",
summary:
"A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.",
statusLabel: "Offline",
statusColor: "#dc2626"
};
case "health_check_healthy":
return {
heading: "Health Check Recovered",
previewText:
"A health check in your organization is now healthy.",
summary:
"A health check in your organization has recovered and is now reporting a healthy status.",
statusLabel: "Healthy",
statusColor: "#16a34a"
};
case "health_check_not_healthy":
return {
heading: "Health Check Failing",
previewText:
"A health check in your organization is not healthy.",
summary:
"A health check in your organization is currently failing. Please review the details below and take action if needed.",
statusLabel: "Not Healthy",
statusColor: "#dc2626"
};
}
}
function formatDataItems(
data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] {
return Object.entries(data)
.filter(([key]) => key !== "orgId")
.map(([key, value]) => ({
label: key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.trim(),
value: String(value ?? "—")
}));
}
export const AlertNotification = ({ eventType, orgId, data }: Props) => {
const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data);
const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId },
{ label: "Status", value: (
<span style={{ color: meta.statusColor, fontWeight: 600 }}>
{meta.statusLabel}
</span>
)},
{ label: "Time", value: new Date().toUTCString() },
...dataItems
];
return (
<Html>
<Head />
<Preview>{meta.previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>{meta.heading}</EmailHeading>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>{meta.summary}</EmailText>
<EmailInfoSection
title="Event Details"
items={allItems}
/>
<EmailText>
Log in to your dashboard to view more details and
manage your alert rules.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default AlertNotification;

View File

@@ -9,8 +9,8 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = { export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet // Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", [LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" [LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
}; };
export const licensePriceSetSandbox: LicensePriceSet = { export const licensePriceSetSandbox: LicensePriceSet = {

View File

@@ -0,0 +1,91 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `health_check_healthy` alert for the given health check.
*
* Call this after a previously-failing health check has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "health_check_healthy",
orgId,
healthCheckId,
data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
/**
* Fire a `health_check_not_healthy` alert for the given health check.
*
* Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckNotHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "health_check_not_healthy",
orgId,
healthCheckId,
data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}

View File

@@ -0,0 +1,91 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `site_online` alert for the given site.
*
* Call this after the site has been confirmed reachable / connected so that
* any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "site_online",
orgId,
siteId,
data: {
siteId,
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}
/**
* Fire a `site_offline` alert for the given site.
*
* Call this after the site has been detected as unreachable / disconnected so
* that any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
): Promise<void> {
try {
await processAlerts({
eventType: "site_offline",
orgId,
siteId,
data: {
siteId,
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}

View File

@@ -0,0 +1,19 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./types";
export * from "./processAlerts";
export * from "./sendAlertWebhook";
export * from "./sendAlertEmail";
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";

View File

@@ -0,0 +1,266 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { and, eq, isNull, or } from "drizzle-orm";
import { db } from "@server/db";
import {
alertRules,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions,
userOrgRoles,
users
} from "@server/db";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { AlertContext, WebhookAlertConfig } from "./types";
import { sendAlertWebhook } from "./sendAlertWebhook";
import { sendAlertEmail } from "./sendAlertEmail";
/**
* Core alert processing pipeline.
*
* Given an `AlertContext`, this function:
* 1. Finds all enabled `alertRules` whose `eventType` matches and whose
* `siteId` / `healthCheckId` matches (or is null, meaning "all").
* 2. Applies per-rule cooldown gating.
* 3. Dispatches emails and webhook POSTs for every attached action.
* 4. Updates `lastTriggeredAt` and `lastSentAt` timestamps.
*/
export async function processAlerts(context: AlertContext): Promise<void> {
const now = Date.now();
// ------------------------------------------------------------------
// 1. Find matching alert rules
// ------------------------------------------------------------------
const siteCondition =
context.siteId != null
? or(
eq(alertRules.siteId, context.siteId),
isNull(alertRules.siteId)
)
: isNull(alertRules.siteId);
const healthCheckCondition =
context.healthCheckId != null
? or(
eq(alertRules.healthCheckId, context.healthCheckId),
isNull(alertRules.healthCheckId)
)
: isNull(alertRules.healthCheckId);
const rules = await db
.select()
.from(alertRules)
.where(
and(
eq(alertRules.orgId, context.orgId),
eq(alertRules.eventType, context.eventType),
eq(alertRules.enabled, true),
// Apply the right scope filter based on event type
context.siteId != null ? siteCondition : healthCheckCondition
)
);
if (rules.length === 0) {
logger.debug(
`processAlerts: no matching rules for event "${context.eventType}" in org "${context.orgId}"`
);
return;
}
for (const rule of rules) {
try {
await processRule(rule, context, now);
} catch (err) {
logger.error(
`processAlerts: error processing rule ${rule.alertRuleId} for event "${context.eventType}"`,
err
);
}
}
}
// ---------------------------------------------------------------------------
// Per-rule processing
// ---------------------------------------------------------------------------
async function processRule(
rule: typeof alertRules.$inferSelect,
context: AlertContext,
now: number
): Promise<void> {
// ------------------------------------------------------------------
// 2. Cooldown check
// ------------------------------------------------------------------
if (
rule.lastTriggeredAt != null &&
now - rule.lastTriggeredAt < rule.cooldownSeconds * 1000
) {
const remainingSeconds = Math.ceil(
(rule.cooldownSeconds * 1000 - (now - rule.lastTriggeredAt)) / 1000
);
logger.debug(
`processAlerts: rule ${rule.alertRuleId} is in cooldown ${remainingSeconds}s remaining`
);
return;
}
// ------------------------------------------------------------------
// 3. Mark rule as triggered (optimistic update before sending so we
// don't re-trigger if the send is slow)
// ------------------------------------------------------------------
await db
.update(alertRules)
.set({ lastTriggeredAt: now })
.where(eq(alertRules.alertRuleId, rule.alertRuleId));
// ------------------------------------------------------------------
// 4. Process email actions
// ------------------------------------------------------------------
const emailActions = await db
.select()
.from(alertEmailActions)
.where(
and(
eq(alertEmailActions.alertRuleId, rule.alertRuleId),
eq(alertEmailActions.enabled, true)
)
);
for (const action of emailActions) {
try {
const recipients = await resolveEmailRecipients(action.emailActionId);
if (recipients.length > 0) {
await sendAlertEmail(recipients, context);
await db
.update(alertEmailActions)
.set({ lastSentAt: now })
.where(
eq(alertEmailActions.emailActionId, action.emailActionId)
);
}
} catch (err) {
logger.error(
`processAlerts: failed to send alert email for action ${action.emailActionId}`,
err
);
}
}
// ------------------------------------------------------------------
// 5. Process webhook actions
// ------------------------------------------------------------------
const webhookActions = await db
.select()
.from(alertWebhookActions)
.where(
and(
eq(alertWebhookActions.alertRuleId, rule.alertRuleId),
eq(alertWebhookActions.enabled, true)
)
);
const serverSecret = config.getRawConfig().server.secret!;
for (const action of webhookActions) {
try {
let webhookConfig: WebhookAlertConfig = { authType: "none" };
if (action.config) {
try {
const decrypted = decrypt(action.config, serverSecret);
webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig;
} catch (err) {
logger.error(
`processAlerts: failed to decrypt webhook config for action ${action.webhookActionId}`,
err
);
continue;
}
}
await sendAlertWebhook(action.webhookUrl, webhookConfig, context);
await db
.update(alertWebhookActions)
.set({ lastSentAt: now })
.where(
eq(
alertWebhookActions.webhookActionId,
action.webhookActionId
)
);
} catch (err) {
logger.error(
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
err
);
}
}
}
// ---------------------------------------------------------------------------
// Email recipient resolution
// ---------------------------------------------------------------------------
/**
* Resolves all email addresses for a given `emailActionId`.
*
* Recipients may be:
* - Direct users (by `userId`)
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
* - Direct external email addresses
*/
async function resolveEmailRecipients(emailActionId: number): Promise<string[]> {
const rows = await db
.select()
.from(alertEmailRecipients)
.where(eq(alertEmailRecipients.emailActionId, emailActionId));
const emailSet = new Set<string>();
for (const row of rows) {
if (row.email) {
emailSet.add(row.email);
}
if (row.userId) {
const [user] = await db
.select({ email: users.email })
.from(users)
.where(eq(users.userId, row.userId))
.limit(1);
if (user?.email) {
emailSet.add(user.email);
}
}
if (row.roleId) {
// Find all users with this role via userOrgRoles
const roleUsers = await db
.select({ email: users.email })
.from(userOrgRoles)
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
.where(eq(userOrgRoles.roleId, Number(row.roleId)));
for (const u of roleUsers) {
if (u.email) {
emailSet.add(u.email);
}
}
}
}
return Array.from(emailSet);
}

View File

@@ -0,0 +1,87 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { sendEmail } from "@server/emails";
import AlertNotification from "@server/emails/templates/AlertNotification";
import config from "@server/lib/config";
import logger from "@server/logger";
import { AlertContext } from "./types";
/**
* Sends an alert notification email to every address in `recipients`.
*
* Each recipient receives an individual email (no BCC list) so that delivery
* failures for one address do not affect the others. Failures per recipient
* are logged and swallowed the caller only sees an error if something goes
* wrong before the send loop.
*/
export async function sendAlertEmail(
recipients: string[],
context: AlertContext
): Promise<void> {
if (recipients.length === 0) {
return;
}
const from = config.getNoReplyEmail();
const subject = buildSubject(context);
for (const to of recipients) {
try {
await sendEmail(
AlertNotification({
eventType: context.eventType,
orgId: context.orgId,
data: context.data
}),
{
from,
to,
subject
}
);
logger.debug(
`Alert email sent to "${to}" for event "${context.eventType}"`
);
} catch (err) {
logger.error(
`sendAlertEmail: failed to send alert email to "${to}" for event "${context.eventType}"`,
err
);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildSubject(context: AlertContext): string {
switch (context.eventType) {
case "site_online":
return "[Alert] Site Back Online";
case "site_offline":
return "[Alert] Site Offline";
case "health_check_healthy":
return "[Alert] Health Check Recovered";
case "health_check_not_healthy":
return "[Alert] Health Check Failing";
default: {
// Exhaustiveness fallback should never be reached with a
// well-typed caller, but keeps runtime behaviour predictable.
const _exhaustive: never = context.eventType;
void _exhaustive;
return "[Alert] Event Notification";
}
}
}

View File

@@ -0,0 +1,132 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { AlertContext, WebhookAlertConfig } from "./types";
const REQUEST_TIMEOUT_MS = 15_000;
/**
* Sends a single webhook POST for an alert event.
*
* The payload shape is:
* ```json
* {
* "event": "site_online",
* "timestamp": "2024-01-01T00:00:00.000Z",
* "data": { ... }
* }
* ```
*
* Authentication headers are applied according to `config.authType`,
* mirroring the same strategies supported by HttpLogDestination:
* none | bearer | basic | custom.
*/
export async function sendAlertWebhook(
url: string,
webhookConfig: WebhookAlertConfig,
context: AlertContext
): Promise<void> {
const payload = {
event: context.eventType,
timestamp: new Date().toISOString(),
data: {
orgId: context.orgId,
...context.data
}
};
const body = JSON.stringify(payload);
const headers = buildHeaders(webhookConfig);
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal
});
} catch (err: unknown) {
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
throw new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Alert webhook: request to "${url}" failed ${msg}`);
} finally {
clearTimeout(timeoutHandle);
}
if (!response.ok) {
let snippet = "";
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
}
throw new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
);
}
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`);
}
// ---------------------------------------------------------------------------
// Header construction (mirrors HttpLogDestination.buildHeaders)
// ---------------------------------------------------------------------------
function buildHeaders(webhookConfig: WebhookAlertConfig): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
};
switch (webhookConfig.authType) {
case "bearer": {
const token = webhookConfig.bearerToken?.trim();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
break;
}
case "basic": {
const creds = webhookConfig.basicCredentials?.trim();
if (creds) {
const encoded = Buffer.from(creds).toString("base64");
headers["Authorization"] = `Basic ${encoded}`;
}
break;
}
case "custom": {
const name = webhookConfig.customHeaderName?.trim();
const value = webhookConfig.customHeaderValue ?? "";
if (name) {
headers[name] = value;
}
break;
}
case "none":
default:
break;
}
return headers;
}

View File

@@ -0,0 +1,59 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
// ---------------------------------------------------------------------------
// Alert event types
// ---------------------------------------------------------------------------
export type AlertEventType =
| "site_online"
| "site_offline"
| "health_check_healthy"
| "health_check_not_healthy";
// ---------------------------------------------------------------------------
// Webhook authentication config (stored as encrypted JSON in the DB)
// ---------------------------------------------------------------------------
export type WebhookAuthType = "none" | "bearer" | "basic" | "custom";
/**
* Stored as an encrypted JSON blob in `alertWebhookActions.config`.
*/
export interface WebhookAlertConfig {
/** Authentication strategy for the webhook endpoint */
authType: WebhookAuthType;
/** Bearer token used when authType === "bearer" */
bearerToken?: string;
/** Basic credentials "username:password" used when authType === "basic" */
basicCredentials?: string;
/** Custom header name used when authType === "custom" */
customHeaderName?: string;
/** Custom header value used when authType === "custom" */
customHeaderValue?: string;
}
// ---------------------------------------------------------------------------
// Internal alert event passed through the processing pipeline
// ---------------------------------------------------------------------------
export interface AlertContext {
eventType: AlertEventType;
orgId: string;
/** Set for site_online / site_offline events */
siteId?: number;
/** Set for health_check_* events */
healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}

View File

@@ -217,7 +217,7 @@ export async function handleSubscriptionCreated(
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
) { ) {
numUsers = 50; numUsers = 50;
numSites = 50; numSites = 100;
} else { } else {
logger.error( logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`

View File

@@ -29,65 +29,9 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listClientsParamsSchema = z.strictObject({ const listClientsParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
@@ -413,44 +357,45 @@ export async function listClients(
}; };
}); });
const latestOlVersionPromise = getLatestOlmVersion(); // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
// const latestOlmVersionPromise = getLatestOlmVersion();
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( // const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
(client) => { // (client) => {
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; // const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
// Initially set to false, will be updated if version check succeeds // // Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false; // OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate; // return OlmWithUpdate;
} // }
); // );
// Try to get the latest version, but don't block if it fails // Try to get the latest version, but don't block if it fails
try { // try {
const latestOlVersion = await latestOlVersionPromise; // const latestOlmVersion = await latestOlVersionPromise;
if (latestOlVersion) { // if (latestOlVersion) {
olmsWithUpdates.forEach((client) => { // olmsWithUpdates.forEach((client) => {
try { // try {
client.olmUpdateAvailable = semver.lt( // client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "", // client.olmVersion ? client.olmVersion : "",
latestOlVersion // latestOlVersion
); // );
} catch (error) { // } catch (error) {
client.olmUpdateAvailable = false; // client.olmUpdateAvailable = false;
} // }
}); // });
} // }
} catch (error) { // } catch (error) {
// Log the error but don't let it block the response // // Log the error but don't let it block the response
logger.warn( // logger.warn(
"Failed to check for OLM updates, continuing without update info:", // "Failed to check for OLM updates, continuing without update info:",
error // error
); // );
} // }
return response<ListClientsResponse>(res, { return response<ListClientsResponse>(res, {
data: { data: {
clients: olmsWithUpdates, clients: clientsWithSites,
pagination: { pagination: {
total: totalCount, total: totalCount,
page, page,

View File

@@ -30,65 +30,10 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver"; import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({ const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
@@ -453,29 +398,30 @@ export async function listUserDevices(
} }
); );
// Try to get the latest version, but don't block if it fails // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
try { // // Try to get the latest version, but don't block if it fails
const latestOlmVersion = await getLatestOlmVersion(); // try {
// const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) { // if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => { // olmsWithUpdates.forEach((client) => {
try { // try {
client.olmUpdateAvailable = semver.lt( // client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "", // client.olmVersion ? client.olmVersion : "",
latestOlmVersion // latestOlmVersion
); // );
} catch (error) { // } catch (error) {
client.olmUpdateAvailable = false; // client.olmUpdateAvailable = false;
} // }
}); // });
} // }
} catch (error) { // } catch (error) {
// Log the error but don't let it block the response // // Log the error but don't let it block the response
logger.warn( // logger.warn(
"Failed to check for OLM updates, continuing without update info:", // "Failed to check for OLM updates, continuing without update info:",
error // error
); // );
} // }
return response<ListUserDevicesResponse>(res, { return response<ListUserDevicesResponse>(res, {
data: { data: {

View File

@@ -21,6 +21,11 @@ import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
// newtUpdateAvailable: false.
let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> { async function getLatestNewtVersion(): Promise<string | null> {
try { try {
const cachedVersion = await cache.get<string>("latestNewtVersion"); const cachedVersion = await cache.get<string>("latestNewtVersion");
@@ -29,7 +34,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
} }
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch( const response = await fetch(
"https://api.github.com/repos/fosrl/newt/tags", "https://api.github.com/repos/fosrl/newt/tags",
@@ -44,18 +49,46 @@ async function getLatestNewtVersion(): Promise<string | null> {
logger.warn( logger.warn(
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
); );
return null; return staleNewtVersion;
} }
let tags = await response.json(); let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) { if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Newt repository"); logger.warn("No tags found for Newt repository");
return null; return staleNewtVersion;
} }
tags = tags.filter((version) => !version.name.includes("rc"));
// Remove release-candidates, then sort descending by semver so that
// duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks
// from the GitHub API do not cause an older tag to be selected.
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
tags.sort((a: any, b: any) => {
const va = semver.coerce(a.name);
const vb = semver.coerce(b.name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
// Deduplicate: keep only the first (highest) entry per normalised version
const seen = new Set<string>();
tags = tags.filter((tag: any) => {
const normalised = semver.coerce(tag.name)?.version;
if (!normalised || seen.has(normalised)) return false;
seen.add(normalised);
return true;
});
if (tags.length === 0) {
logger.warn("No valid semver tags found for Newt repository");
return staleNewtVersion;
}
const latestVersion = tags[0].name; const latestVersion = tags[0].name;
await cache.set("latestNewtVersion", latestVersion, 3600); staleNewtVersion = latestVersion;
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
return latestVersion; return latestVersion;
} catch (error: any) { } catch (error: any) {
@@ -73,7 +106,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
error.message || error error.message || error
); );
} }
return null; return staleNewtVersion;
} }
} }

View File

@@ -340,7 +340,8 @@ 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
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`, `/org/${orgId}/create-invite`,
{ {
email: values.email, email: values.email,

View 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}
/>
);
}

View 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
/>
);
}

View File

@@ -0,0 +1,24 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import AlertingRulesTable from "@app/components/AlertingRulesTable";
import { getTranslations } from "next-intl/server";
type AlertingPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function AlertingPage(props: AlertingPageProps) {
const params = await props.params;
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<AlertingRulesTable orgId={params.orgId} />
</>
);
}

View File

@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env"; import { Env } from "@app/lib/types/env";
import { build } from "@server/build"; import { build } from "@server/build";
import { import {
BellRing,
Boxes, Boxes,
Building2, Building2,
Cable, Cable,
@@ -225,6 +226,11 @@ export const orgNavSections = (
title: "sidebarBluePrints", title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints", href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" /> icon: <ReceiptText className="size-4 flex-none" />
},
{
title: "sidebarAlerting",
href: "/{orgId}/settings/alerting",
icon: <BellRing className="size-4 flex-none" />
} }
] ]
}, },

View File

@@ -0,0 +1,282 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import {
type AlertRule,
deleteRule,
isoNow,
loadRules,
upsertRule
} from "@app/lib/alertRulesLocalStorage";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import moment from "moment";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@app/components/ui/badge";
type AlertingRulesTableProps = {
orgId: 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") {
return t("alertingSummarySites", {
count: rule.source.siteIds.length
});
}
return t("alertingSummaryHealthChecks", {
count: rule.source.targetIds.length
});
}
function triggerLabel(rule: AlertRule, t: (k: string) => string) {
switch (rule.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 rule.trigger;
}
}
function actionBadges(rule: AlertRule, t: (k: string) => string) {
return rule.actions.map((a, i) => {
if (a.type === "notify") {
return (
<Badge key={`notify-${i}`} variant="secondary">
{t("alertingActionNotify")}
</Badge>
);
}
if (a.type === "sms") {
return (
<Badge key={`sms-${i}`} variant="secondary">
{t("alertingActionSms")}
</Badge>
);
}
return (
<Badge key={`webhook-${i}`} variant="secondary">
{t("alertingActionWebhook")}
</Badge>
);
});
}
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
const router = useRouter();
const t = useTranslations();
const [rows, setRows] = useState<AlertRule[]>([]);
const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<AlertRule | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshFromStorage = useCallback(() => {
setRows(loadRules(orgId));
}, [orgId]);
useEffect(() => {
refreshFromStorage();
}, [refreshFromStorage]);
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((r) => setTimeout(r, 200));
refreshFromStorage();
} finally {
setIsRefreshing(false);
}
};
const setEnabled = (rule: AlertRule, enabled: boolean) => {
upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() });
refreshFromStorage();
};
const confirmDelete = async () => {
if (!selected) return;
deleteRule(orgId, selected.id);
refreshFromStorage();
setDeleteOpen(false);
setSelected(null);
toast({ title: t("alertingRuleDeleted") });
};
const columns: ExtendedColumnDef<AlertRule>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
)
},
{
id: "source",
friendlyName: t("alertingColumnSource"),
header: () => (
<span className="p-3">{t("alertingColumnSource")}</span>
),
cell: ({ row }) => <span>{sourceSummary(row.original, t)}</span>
},
{
id: "trigger",
friendlyName: t("alertingColumnTrigger"),
header: () => (
<span className="p-3">{t("alertingColumnTrigger")}</span>
),
cell: ({ row }) => <span>{triggerLabel(row.original, t)}</span>
},
{
id: "actionsCol",
friendlyName: t("alertingColumnActions"),
header: () => (
<span className="p-3">{t("alertingColumnActions")}</span>
),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{actionBadges(row.original, t)}
</div>
)
},
{
accessorKey: "enabled",
friendlyName: t("alertingColumnEnabled"),
header: () => (
<span className="p-3">{t("alertingColumnEnabled")}</span>
),
cell: ({ row }) => {
const r = row.original;
return (
<Switch
checked={r.enabled}
onCheckedChange={(v) => setEnabled(r, v)}
/>
);
}
},
{
accessorKey: "createdAt",
friendlyName: t("createdAt"),
header: () => <span className="p-3">{t("createdAt")}</span>,
cell: ({ row }) => (
<span>{moment(row.original.createdAt).format("lll")}</span>
)
},
{
id: "rowActions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setDeleteOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" asChild>
<Link href={ruleHref(orgId, r.id)}>
{t("edit")}
</Link>
</Button>
</div>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(val) => {
setDeleteOpen(val);
if (!val) setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("alertingDeleteQuestion")}</p>
</div>
}
buttonText={t("delete")}
onConfirm={confirmDelete}
string={selected.name}
title={t("alertingDeleteRule")}
/>
)}
<DataTable
columns={columns}
data={rows}
persistPageSize="Org-alerting-rules-table"
title={t("alertingRules")}
searchPlaceholder={t("alertingSearchRules")}
searchColumn="name"
onAdd={() => {
router.push(`/${orgId}/settings/alerting/create`);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
addButtonText={t("alertingAddRule")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
/>
</>
);
}

View 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>
)}
/>
);
}

View 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
View 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
};
}

View File

@@ -0,0 +1,133 @@
import { z } from "zod";
const STORAGE_PREFIX = "pangolin:alert-rules:";
export const webhookHeaderEntrySchema = z.object({
key: z.string(),
value: z.string()
});
export const alertActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
emails: z.array(z.string())
}),
z.object({
type: z.literal("sms"),
phoneNumbers: z.array(z.string())
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),
method: z.string().min(1),
headers: z.array(webhookHeaderEntrySchema),
secret: z.string().optional()
})
]);
export const alertSourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("site"),
siteIds: z.array(z.number())
}),
z.object({
type: z.literal("health_check"),
targetIds: z.array(z.number())
})
]);
export const alertTriggerSchema = z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]);
export const alertRuleSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
source: alertSourceSchema,
trigger: alertTriggerSchema,
actions: z.array(alertActionSchema).min(1)
});
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
function storageKey(orgId: string) {
return `${STORAGE_PREFIX}${orgId}`;
}
export function getRule(orgId: string, ruleId: string): AlertRule | undefined {
return loadRules(orgId).find((r) => r.id === ruleId);
}
export function loadRules(orgId: string): AlertRule[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(storageKey(orgId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const out: AlertRule[] = [];
for (const item of parsed) {
const r = alertRuleSchema.safeParse(item);
if (r.success) {
out.push(r.data);
}
}
return out;
} catch {
return [];
}
}
export function saveRules(orgId: string, rules: AlertRule[]) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
}
export function upsertRule(orgId: string, rule: AlertRule) {
const rules = loadRules(orgId);
const i = rules.findIndex((r) => r.id === rule.id);
if (i >= 0) {
rules[i] = rule;
} else {
rules.push(rule);
}
saveRules(orgId, rules);
}
export function deleteRule(orgId: string, ruleId: string) {
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
saveRules(orgId, rules);
}
export function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function isoNow() {
return new Date().toISOString();
}