Compare commits
10 Commits
dependabot
...
alerting-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d50703c26 | ||
|
|
33182bcf85 | ||
|
|
fb29efeff3 | ||
|
|
ae36d3228f | ||
|
|
1c78a6b483 | ||
|
|
af709331fb | ||
|
|
e20a21bacd | ||
|
|
74b3b283f7 | ||
|
|
4cce6e0820 | ||
|
|
2841c5ed4e |
1
config/db/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*-journal
|
||||
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,6 +33,6 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"actionViewLogs": "View Logs",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"search": "Search…",
|
||||
"searchPlaceholder": "Search...",
|
||||
"emptySearchOptions": "No options found",
|
||||
"create": "Create",
|
||||
@@ -1340,10 +1341,96 @@
|
||||
"sidebarGeneral": "Manage",
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Blueprints",
|
||||
"sidebarAlerting": "Alerting",
|
||||
"sidebarOrganization": "Organization",
|
||||
"sidebarManagement": "Management",
|
||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||
"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",
|
||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||
"blueprintAdd": "Add Blueprint",
|
||||
@@ -2351,7 +2438,7 @@
|
||||
},
|
||||
"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)",
|
||||
@@ -2824,9 +2911,9 @@
|
||||
"streamingHttpWebhookTitle": "HTTP Webhook",
|
||||
"streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.",
|
||||
"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",
|
||||
"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.",
|
||||
"streamingFailedToLoad": "Failed to load destinations",
|
||||
"streamingUnexpectedError": "An unexpected error occurred.",
|
||||
@@ -2849,7 +2936,7 @@
|
||||
"httpDestNamePlaceholder": "My HTTP destination",
|
||||
"httpDestUrlLabel": "Destination URL",
|
||||
"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)",
|
||||
"httpDestAuthTitle": "Authentication",
|
||||
"httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.",
|
||||
|
||||
136
package-lock.json
generated
@@ -44,6 +44,7 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
@@ -89,13 +90,13 @@
|
||||
"reodotdev": "1.1.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "^1.18.0",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "20.4.1",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "^10.1.0",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
@@ -130,7 +131,7 @@
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "^1.17.4",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -1058,6 +1059,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2353,6 +2355,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2375,6 +2378,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2397,6 +2401,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2413,6 +2418,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2429,6 +2435,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2445,6 +2452,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2461,6 +2469,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2477,6 +2486,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2493,6 +2503,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2509,6 +2520,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2525,6 +2537,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2541,6 +2554,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2563,6 +2577,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2585,6 +2600,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2607,6 +2623,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2629,6 +2646,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2651,6 +2669,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2673,6 +2692,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2695,6 +2715,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2714,6 +2735,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2733,6 +2755,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2752,6 +2775,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3011,6 +3035,7 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -6957,6 +6982,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -8417,6 +8443,7 @@
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -8532,6 +8559,7 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -8691,7 +8719,6 @@
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
@@ -8807,7 +8834,6 @@
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
@@ -8842,7 +8868,6 @@
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
@@ -8852,7 +8877,6 @@
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
@@ -8879,6 +8903,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -8974,6 +8999,7 @@
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -9001,6 +9027,7 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -9026,6 +9053,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9036,6 +9064,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -9122,8 +9151,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
@@ -9197,6 +9225,7 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -9651,6 +9680,38 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
|
||||
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.68",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
|
||||
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -9670,6 +9731,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -10118,6 +10180,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -10189,6 +10252,7 @@
|
||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -10317,6 +10381,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -10499,6 +10564,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-spinners": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
||||
@@ -11223,6 +11294,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -11663,7 +11735,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -12298,6 +12369,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -12383,6 +12455,7 @@
|
||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -12519,6 +12592,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -12912,6 +12986,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -15329,7 +15404,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -15340,7 +15414,6 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -15429,6 +15502,7 @@
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
||||
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.14",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -16388,6 +16462,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -16892,6 +16967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -16923,6 +16999,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -17215,6 +17292,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -18676,7 +18754,8 @@
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -19151,6 +19230,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -19578,6 +19658,7 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -19784,6 +19865,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -19799,6 +19881,34 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 569 KiB After Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 243 KiB |
@@ -16,11 +16,13 @@ import {
|
||||
domains,
|
||||
orgs,
|
||||
targets,
|
||||
roles,
|
||||
users,
|
||||
exitNodes,
|
||||
sessions,
|
||||
clients,
|
||||
siteResources,
|
||||
targetHealthCheck,
|
||||
sites
|
||||
} from "./schema";
|
||||
|
||||
@@ -425,7 +427,9 @@ export const eventStreamingDestinations = pgTable(
|
||||
orgId: varchar("orgId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false),
|
||||
sendConnectionLogs: boolean("sendConnectionLogs")
|
||||
.notNull()
|
||||
.default(false),
|
||||
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
||||
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
||||
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
||||
@@ -447,7 +451,9 @@ export const eventStreamingCursors = pgTable(
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
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
|
||||
},
|
||||
(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 Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
|
||||
@@ -1080,6 +1080,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
domains,
|
||||
exitNodes,
|
||||
orgs,
|
||||
roles,
|
||||
sessions,
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
users
|
||||
} 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 Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
|
||||
140
server/emails/templates/AlertNotification.tsx
Normal 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;
|
||||
@@ -9,8 +9,8 @@ export type LicensePriceSet = {
|
||||
|
||||
export const licensePriceSet: LicensePriceSet = {
|
||||
// Free license matches the freeLimitSet
|
||||
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
|
||||
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
|
||||
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
|
||||
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
|
||||
};
|
||||
|
||||
export const licensePriceSetSandbox: LicensePriceSet = {
|
||||
|
||||
91
server/private/lib/alerts/events/healthCheckEvents.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
91
server/private/lib/alerts/events/siteEvents.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
19
server/private/lib/alerts/index.ts
Normal 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";
|
||||
266
server/private/lib/alerts/processAlerts.ts
Normal 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);
|
||||
}
|
||||
87
server/private/lib/alerts/sendAlertEmail.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
132
server/private/lib/alerts/sendAlertWebhook.ts
Normal 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;
|
||||
}
|
||||
59
server/private/lib/alerts/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export async function handleSubscriptionCreated(
|
||||
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
|
||||
) {
|
||||
numUsers = 50;
|
||||
numSites = 50;
|
||||
numSites = 100;
|
||||
} else {
|
||||
logger.error(
|
||||
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
||||
|
||||
@@ -29,65 +29,9 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
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({
|
||||
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(
|
||||
(client) => {
|
||||
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
OlmWithUpdate.olmUpdateAvailable = false;
|
||||
return OlmWithUpdate;
|
||||
}
|
||||
);
|
||||
// const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
|
||||
// (client) => {
|
||||
// const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
|
||||
// // Initially set to false, will be updated if version check succeeds
|
||||
// OlmWithUpdate.olmUpdateAvailable = false;
|
||||
// return OlmWithUpdate;
|
||||
// }
|
||||
// );
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
const latestOlVersion = await latestOlVersionPromise;
|
||||
// try {
|
||||
// const latestOlmVersion = await latestOlVersionPromise;
|
||||
|
||||
if (latestOlVersion) {
|
||||
olmsWithUpdates.forEach((client) => {
|
||||
try {
|
||||
client.olmUpdateAvailable = semver.lt(
|
||||
client.olmVersion ? client.olmVersion : "",
|
||||
latestOlVersion
|
||||
);
|
||||
} catch (error) {
|
||||
client.olmUpdateAvailable = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't let it block the response
|
||||
logger.warn(
|
||||
"Failed to check for OLM updates, continuing without update info:",
|
||||
error
|
||||
);
|
||||
}
|
||||
// if (latestOlVersion) {
|
||||
// olmsWithUpdates.forEach((client) => {
|
||||
// try {
|
||||
// client.olmUpdateAvailable = semver.lt(
|
||||
// client.olmVersion ? client.olmVersion : "",
|
||||
// latestOlVersion
|
||||
// );
|
||||
// } catch (error) {
|
||||
// client.olmUpdateAvailable = false;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // Log the error but don't let it block the response
|
||||
// logger.warn(
|
||||
// "Failed to check for OLM updates, continuing without update info:",
|
||||
// error
|
||||
// );
|
||||
// }
|
||||
|
||||
return response<ListClientsResponse>(res, {
|
||||
data: {
|
||||
clients: olmsWithUpdates,
|
||||
clients: clientsWithSites,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
|
||||
@@ -30,65 +30,10 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
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({
|
||||
orgId: z.string()
|
||||
});
|
||||
@@ -453,29 +398,30 @@ export async function listUserDevices(
|
||||
}
|
||||
);
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
const latestOlmVersion = await getLatestOlmVersion();
|
||||
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
|
||||
// // Try to get the latest version, but don't block if it fails
|
||||
// try {
|
||||
// const latestOlmVersion = await getLatestOlmVersion();
|
||||
|
||||
if (latestOlmVersion) {
|
||||
olmsWithUpdates.forEach((client) => {
|
||||
try {
|
||||
client.olmUpdateAvailable = semver.lt(
|
||||
client.olmVersion ? client.olmVersion : "",
|
||||
latestOlmVersion
|
||||
);
|
||||
} catch (error) {
|
||||
client.olmUpdateAvailable = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't let it block the response
|
||||
logger.warn(
|
||||
"Failed to check for OLM updates, continuing without update info:",
|
||||
error
|
||||
);
|
||||
}
|
||||
// if (latestOlmVersion) {
|
||||
// olmsWithUpdates.forEach((client) => {
|
||||
// try {
|
||||
// client.olmUpdateAvailable = semver.lt(
|
||||
// client.olmVersion ? client.olmVersion : "",
|
||||
// latestOlmVersion
|
||||
// );
|
||||
// } catch (error) {
|
||||
// client.olmUpdateAvailable = false;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // Log the error but don't let it block the response
|
||||
// logger.warn(
|
||||
// "Failed to check for OLM updates, continuing without update info:",
|
||||
// error
|
||||
// );
|
||||
// }
|
||||
|
||||
return response<ListUserDevicesResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -21,6 +21,11 @@ import semver from "semver";
|
||||
import { z } from "zod";
|
||||
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> {
|
||||
try {
|
||||
const cachedVersion = await cache.get<string>("latestNewtVersion");
|
||||
@@ -29,7 +34,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
||||
}
|
||||
|
||||
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(
|
||||
"https://api.github.com/repos/fosrl/newt/tags",
|
||||
@@ -44,18 +49,46 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
||||
logger.warn(
|
||||
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return null;
|
||||
return staleNewtVersion;
|
||||
}
|
||||
|
||||
let tags = await response.json();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
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;
|
||||
|
||||
await cache.set("latestNewtVersion", latestVersion, 3600);
|
||||
staleNewtVersion = latestVersion;
|
||||
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
|
||||
|
||||
return latestVersion;
|
||||
} catch (error: any) {
|
||||
@@ -73,7 +106,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return staleNewtVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,15 +340,16 @@ export default function Page() {
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleIds,
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail
|
||||
}
|
||||
)
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleIds,
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
|
||||
52
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||
import { ruleToFormValues } from "@app/lib/alertRuleForm";
|
||||
import type { AlertRule } from "@app/lib/alertRulesLocalStorage";
|
||||
import { getRule } from "@app/lib/alertRulesLocalStorage";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function EditAlertRulePage() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const orgId = params.orgId as string;
|
||||
const ruleId = params.ruleId as string;
|
||||
const [rule, setRule] = useState<AlertRule | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const r = getRule(orgId, ruleId);
|
||||
setRule(r ?? null);
|
||||
}, [orgId, ruleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rule === null) {
|
||||
router.replace(`/${orgId}/settings/alerting`);
|
||||
}
|
||||
}, [rule, orgId, router]);
|
||||
|
||||
if (rule === undefined) {
|
||||
return (
|
||||
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rule === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertRuleGraphEditor
|
||||
key={rule.id}
|
||||
orgId={orgId}
|
||||
ruleId={rule.id}
|
||||
createdAt={rule.createdAt}
|
||||
initialValues={ruleToFormValues(rule)}
|
||||
isNew={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/app/[orgId]/settings/alerting/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Boxes,
|
||||
Building2,
|
||||
Cable,
|
||||
@@ -225,6 +226,11 @@ export const orgNavSections = (
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarAlerting",
|
||||
href: "/{orgId}/settings/alerting",
|
||||
icon: <BellRing className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
282
src/components/AlertingRulesTable.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
981
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { TagInput } from "@app/components/tags/tag-input";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import {
|
||||
type AlertRuleFormAction,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export function DropdownAddAction({
|
||||
onPick
|
||||
}: {
|
||||
onPick: (type: "notify" | "sms" | "webhook") => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddAction")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-2" align="end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("notify");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionNotify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("sms");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionSms")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("webhook");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionWebhook")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced] = useDebounce(q, 150);
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({ orgId, query: debounced, perPage: 500 })
|
||||
);
|
||||
const toggle = (id: number) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectSites")
|
||||
: t("alertingSitesSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
value={q}
|
||||
onValueChange={setQ}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((s) => (
|
||||
<CommandItem
|
||||
key={s.siteId}
|
||||
value={`${s.siteId}`}
|
||||
onSelect={() => toggle(s.siteId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(s.siteId)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{s.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ALERT_RESOURCES_PAGE_SIZE = 10;
|
||||
|
||||
function ResourceTenMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.resources({
|
||||
orgId,
|
||||
perPage: ALERT_RESOURCES_PAGE_SIZE
|
||||
})
|
||||
);
|
||||
const rows = useMemo(() => {
|
||||
const out: {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
targetIds: number[];
|
||||
}[] = [];
|
||||
for (const r of resources) {
|
||||
const targetIds = r.targets.map((x) => x.targetId);
|
||||
if (targetIds.length > 0) {
|
||||
out.push({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
targetIds
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [resources]);
|
||||
|
||||
const selectedResourceCount = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.targetIds.length > 0 &&
|
||||
row.targetIds.every((id) => value.includes(id))
|
||||
).length,
|
||||
[rows, value]
|
||||
);
|
||||
|
||||
const toggle = (targetIds: number[]) => {
|
||||
const allOn =
|
||||
targetIds.length > 0 &&
|
||||
targetIds.every((id) => value.includes(id));
|
||||
if (allOn) {
|
||||
onChange(value.filter((id) => !targetIds.includes(id)));
|
||||
} else {
|
||||
onChange([...new Set([...value, ...targetIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const summary =
|
||||
selectedResourceCount === 0
|
||||
? t("alertingSelectResources")
|
||||
: t("alertingResourcesSelected", {
|
||||
count: selectedResourceCount
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto p-2 space-y-0.5">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-2 py-1.5">
|
||||
{t("alertingResourcesEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const checked =
|
||||
row.targetIds.length > 0 &&
|
||||
row.targetIds.every((id) =>
|
||||
value.includes(id)
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={row.resourceId}
|
||||
type="button"
|
||||
onClick={() => toggle(row.targetIds)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
className="pointer-events-none shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionBlock({
|
||||
orgId,
|
||||
index,
|
||||
control,
|
||||
form,
|
||||
onRemove,
|
||||
canRemove
|
||||
}: {
|
||||
orgId: string;
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const type = form.watch(`actions.${index}.type`);
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-3 relative">
|
||||
{canRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.type`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingActionType")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
const nt = v as AlertRuleFormAction["type"];
|
||||
if (nt === "notify") {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "notify",
|
||||
userIds: [],
|
||||
roleIds: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else if (nt === "sms") {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "sms",
|
||||
phoneTags: []
|
||||
});
|
||||
} else {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [{ key: "", value: "" }],
|
||||
secret: ""
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="notify">
|
||||
{t("alertingActionNotify")}
|
||||
</SelectItem>
|
||||
<SelectItem value="sms">
|
||||
{t("alertingActionSms")}
|
||||
</SelectItem>
|
||||
<SelectItem value="webhook">
|
||||
{t("alertingActionWebhook")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "notify" && (
|
||||
<NotifyActionFields
|
||||
orgId={orgId}
|
||||
index={index}
|
||||
control={control}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{type === "sms" && (
|
||||
<SmsActionFields index={index} control={control} form={form} />
|
||||
)}
|
||||
{type === "webhook" && (
|
||||
<WebhookActionFields
|
||||
index={index}
|
||||
control={control}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyActionFields({
|
||||
orgId,
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
orgId: string;
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
||||
const userIds = form.watch(`actions.${index}.userIds`) ?? [];
|
||||
const roleIds = form.watch(`actions.${index}.roleIds`) ?? [];
|
||||
const emailTags = form.watch(`actions.${index}.emailTags`) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-1">
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||
<UserMultiSelect
|
||||
orgId={orgId}
|
||||
value={userIds}
|
||||
onChange={(ids) =>
|
||||
form.setValue(`actions.${index}.userIds`, ids)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||
<RoleMultiSelect
|
||||
orgId={orgId}
|
||||
value={roleIds}
|
||||
onChange={(ids) =>
|
||||
form.setValue(`actions.${index}.roleIds`, ids)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.emailTags`}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyEmails")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
tags={emailTags}
|
||||
setTags={(updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater(emailTags)
|
||||
: updater;
|
||||
form.setValue(
|
||||
`actions.${index}.emailTags`,
|
||||
next
|
||||
);
|
||||
}}
|
||||
activeTagIndex={emailActiveIdx}
|
||||
setActiveTagIndex={setEmailActiveIdx}
|
||||
placeholder={t("alertingEmailPlaceholder")}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsActionFields({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [phoneActiveIdx, setPhoneActiveIdx] = useState<number | null>(null);
|
||||
const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? [];
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.phoneTags`}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingSmsNumbers")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
tags={phoneTags}
|
||||
setTags={(updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater(phoneTags)
|
||||
: updater;
|
||||
form.setValue(
|
||||
`actions.${index}.phoneTags`,
|
||||
next
|
||||
);
|
||||
}}
|
||||
activeTagIndex={phoneActiveIdx}
|
||||
setActiveTagIndex={setPhoneActiveIdx}
|
||||
placeholder={t("alertingSmsPlaceholder")}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookActionFields({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<div className="space-y-3 pt-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.url`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://example.com/hook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.method`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingWebhookMethod")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["GET", "POST", "PUT", "PATCH"].map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.secret`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingWebhookSecret")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
"alertingWebhookSecretPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<WebhookHeadersField index={index} control={control} form={form} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookHeadersField({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const headers =
|
||||
form.watch(`actions.${index}.headers` as const) ?? [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("alertingWebhookHeaders")}</FormLabel>
|
||||
{headers.map((_, hi) => (
|
||||
<div key={hi} className="flex gap-2 items-start">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.headers.${hi}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Key" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.headers.${hi}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Value" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
const cur =
|
||||
form.getValues(
|
||||
`actions.${index}.headers`
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`actions.${index}.headers`,
|
||||
cur.filter((__, i) => i !== hi)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const cur =
|
||||
form.getValues(`actions.${index}.headers`) ?? [];
|
||||
form.setValue(`actions.${index}.headers`, [
|
||||
...cur,
|
||||
{ key: "", value: "" }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddHeader")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced] = useDebounce(q, 150);
|
||||
const { data: users = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const shown = useMemo(() => {
|
||||
const qq = debounced.trim().toLowerCase();
|
||||
if (!qq) return users.slice(0, 200);
|
||||
return users
|
||||
.filter((u) => {
|
||||
const label = getUserDisplayName({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
username: u.username
|
||||
}).toLowerCase();
|
||||
return (
|
||||
label.includes(qq) ||
|
||||
(u.email ?? "").toLowerCase().includes(qq)
|
||||
);
|
||||
})
|
||||
.slice(0, 200);
|
||||
}, [users, debounced]);
|
||||
const toggle = (id: string) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectUsers")
|
||||
: t("alertingUsersSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onValueChange={setQ}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shown.map((u) => {
|
||||
const uid = String(u.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={uid}
|
||||
value={uid}
|
||||
onSelect={() => toggle(uid)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(uid)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{getUserDisplayName({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
username: u.username
|
||||
})}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: roles = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
const toggle = (id: number) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectRoles")
|
||||
: t("alertingRolesSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{roles.map((r) => (
|
||||
<CommandItem
|
||||
key={r.roleId}
|
||||
value={`role-${r.roleId}`}
|
||||
onSelect={() => toggle(r.roleId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(r.roleId)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{r.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertRuleSourceFields({
|
||||
orgId,
|
||||
control
|
||||
}: {
|
||||
orgId: string;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { setValue, getValues } = useFormContext<AlertRuleFormValues>();
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sourceType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingSourceType")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
const next = v as AlertRuleFormValues["sourceType"];
|
||||
field.onChange(next);
|
||||
const curTrigger = getValues("trigger");
|
||||
if (next === "site") {
|
||||
if (
|
||||
curTrigger !== "site_online" &&
|
||||
curTrigger !== "site_offline"
|
||||
) {
|
||||
setValue("trigger", "site_offline", {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
curTrigger !== "health_check_healthy" &&
|
||||
curTrigger !== "health_check_unhealthy"
|
||||
) {
|
||||
setValue(
|
||||
"trigger",
|
||||
"health_check_unhealthy",
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
{t("alertingSourceSite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="health_check">
|
||||
{t("alertingSourceHealthCheck")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{sourceType === "site" ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="siteIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingPickSites")}</FormLabel>
|
||||
<SiteMultiSelect
|
||||
orgId={orgId}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name="targetIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingPickResources")}</FormLabel>
|
||||
<ResourceTenMultiSelect
|
||||
orgId={orgId}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertRuleTriggerFields({
|
||||
control
|
||||
}: {
|
||||
control: Control<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="trigger"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingTrigger")}</FormLabel>
|
||||
<Select
|
||||
key={sourceType}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{sourceType === "site" ? (
|
||||
<>
|
||||
<SelectItem value="site_online">
|
||||
{t("alertingTriggerSiteOnline")}
|
||||
</SelectItem>
|
||||
<SelectItem value="site_offline">
|
||||
{t("alertingTriggerSiteOffline")}
|
||||
</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="health_check_healthy">
|
||||
{t("alertingTriggerHcHealthy")}
|
||||
</SelectItem>
|
||||
<SelectItem value="health_check_unhealthy">
|
||||
{t("alertingTriggerHcUnhealthy")}
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
715
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
133
src/lib/alertRulesLocalStorage.ts
Normal 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();
|
||||
}
|
||||