Compare commits
6 Commits
alerting-r
...
1.17.1-s.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe4f78269 | ||
|
|
bd3d6994c1 | ||
|
|
5fd78817a8 | ||
|
|
72bc125f84 | ||
|
|
b18ea66def | ||
|
|
41f541a531 |
1
config/db/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
*-journal
|
|
||||||
@@ -1256,7 +1256,6 @@
|
|||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"search": "Search…",
|
|
||||||
"searchPlaceholder": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
"emptySearchOptions": "No options found",
|
"emptySearchOptions": "No options found",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@@ -1341,96 +1340,10 @@
|
|||||||
"sidebarGeneral": "Manage",
|
"sidebarGeneral": "Manage",
|
||||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
"sidebarAlerting": "Alerting",
|
|
||||||
"sidebarOrganization": "Organization",
|
"sidebarOrganization": "Organization",
|
||||||
"sidebarManagement": "Management",
|
"sidebarManagement": "Management",
|
||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
"alertingTitle": "Alerting rules",
|
|
||||||
"alertingDescription": "Define sources, triggers, and actions for notifications.",
|
|
||||||
"alertingRules": "Alert rules",
|
|
||||||
"alertingSearchRules": "Search rules…",
|
|
||||||
"alertingAddRule": "Create Rule",
|
|
||||||
"alertingColumnSource": "Source",
|
|
||||||
"alertingColumnTrigger": "Trigger",
|
|
||||||
"alertingColumnActions": "Actions",
|
|
||||||
"alertingColumnEnabled": "Enabled",
|
|
||||||
"alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.",
|
|
||||||
"alertingDeleteRule": "Delete alert rule",
|
|
||||||
"alertingRuleDeleted": "Alert rule deleted",
|
|
||||||
"alertingRuleSaved": "Alert rule saved",
|
|
||||||
"alertingEditRule": "Edit alert rule",
|
|
||||||
"alertingCreateRule": "Create alert rule",
|
|
||||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
|
|
||||||
"alertingRuleNamePlaceholder": "Production site down",
|
|
||||||
"alertingRuleEnabled": "Rule enabled",
|
|
||||||
"alertingSectionSource": "Source",
|
|
||||||
"alertingSourceType": "Source type",
|
|
||||||
"alertingSourceSite": "Site",
|
|
||||||
"alertingSourceHealthCheck": "Health check",
|
|
||||||
"alertingPickSites": "Sites",
|
|
||||||
"alertingPickHealthChecks": "Health checks",
|
|
||||||
"alertingPickResources": "Resources",
|
|
||||||
"alertingSelectResources": "Select resources…",
|
|
||||||
"alertingResourcesSelected": "{count} resources selected",
|
|
||||||
"alertingResourcesEmpty": "No resources with targets in the first 10 results.",
|
|
||||||
"alertingSectionTrigger": "Trigger",
|
|
||||||
"alertingTrigger": "When to alert",
|
|
||||||
"alertingTriggerSiteOnline": "Site online",
|
|
||||||
"alertingTriggerSiteOffline": "Site offline",
|
|
||||||
"alertingTriggerHcHealthy": "Health check healthy",
|
|
||||||
"alertingTriggerHcUnhealthy": "Health check unhealthy",
|
|
||||||
"alertingSectionActions": "Actions",
|
|
||||||
"alertingAddAction": "Add action",
|
|
||||||
"alertingActionNotify": "Notify",
|
|
||||||
"alertingActionSms": "SMS",
|
|
||||||
"alertingActionWebhook": "Webhook",
|
|
||||||
"alertingActionType": "Action type",
|
|
||||||
"alertingNotifyUsers": "Users",
|
|
||||||
"alertingNotifyRoles": "Roles",
|
|
||||||
"alertingNotifyEmails": "Email addresses",
|
|
||||||
"alertingEmailPlaceholder": "Add email and press Enter",
|
|
||||||
"alertingSmsNumbers": "Phone numbers",
|
|
||||||
"alertingSmsPlaceholder": "Add number and press Enter",
|
|
||||||
"alertingWebhookMethod": "HTTP method",
|
|
||||||
"alertingWebhookSecret": "Signing secret (optional)",
|
|
||||||
"alertingWebhookSecretPlaceholder": "HMAC secret",
|
|
||||||
"alertingWebhookHeaders": "Headers",
|
|
||||||
"alertingAddHeader": "Add header",
|
|
||||||
"alertingSelectSites": "Select sites…",
|
|
||||||
"alertingSitesSelected": "{count} sites selected",
|
|
||||||
"alertingSelectHealthChecks": "Select health checks…",
|
|
||||||
"alertingHealthChecksSelected": "{count} health checks selected",
|
|
||||||
"alertingNoHealthChecks": "No targets with health checks enabled",
|
|
||||||
"alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.",
|
|
||||||
"alertingSelectUsers": "Select users…",
|
|
||||||
"alertingUsersSelected": "{count} users selected",
|
|
||||||
"alertingSelectRoles": "Select roles…",
|
|
||||||
"alertingRolesSelected": "{count} roles selected",
|
|
||||||
"alertingSummarySites": "Sites ({count})",
|
|
||||||
"alertingSummaryHealthChecks": "Health checks ({count})",
|
|
||||||
"alertingErrorNameRequired": "Enter a name",
|
|
||||||
"alertingErrorActionsMin": "Add at least one action",
|
|
||||||
"alertingErrorPickSites": "Select at least one site",
|
|
||||||
"alertingErrorPickHealthChecks": "Select at least one health check",
|
|
||||||
"alertingErrorTriggerSite": "Choose a site trigger",
|
|
||||||
"alertingErrorTriggerHealth": "Choose a health check trigger",
|
|
||||||
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
|
|
||||||
"alertingErrorSmsPhones": "Add at least one phone number",
|
|
||||||
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
|
|
||||||
"alertingConfigureSource": "Configure Source",
|
|
||||||
"alertingConfigureTrigger": "Configure Trigger",
|
|
||||||
"alertingConfigureActions": "Configure Actions",
|
|
||||||
"alertingBackToRules": "Back to Rules",
|
|
||||||
"alertingDraftBadge": "Draft — save to store this rule",
|
|
||||||
"alertingSidebarHint": "Click a step on the canvas to edit it here.",
|
|
||||||
"alertingGraphCanvasTitle": "Rule Flow",
|
|
||||||
"alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.",
|
|
||||||
"alertingNodeNotConfigured": "Not configured yet",
|
|
||||||
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
|
|
||||||
"alertingNodeRoleSource": "Source",
|
|
||||||
"alertingNodeRoleTrigger": "Trigger",
|
|
||||||
"alertingNodeRoleAction": "Action",
|
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
@@ -2438,7 +2351,7 @@
|
|||||||
},
|
},
|
||||||
"scale": {
|
"scale": {
|
||||||
"title": "Scale",
|
"title": "Scale",
|
||||||
"description": "Enterprise features, 50 users, 100 sites, and priority support."
|
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||||
@@ -2911,9 +2824,9 @@
|
|||||||
"streamingHttpWebhookTitle": "HTTP Webhook",
|
"streamingHttpWebhookTitle": "HTTP Webhook",
|
||||||
"streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.",
|
"streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.",
|
||||||
"streamingS3Title": "Amazon S3",
|
"streamingS3Title": "Amazon S3",
|
||||||
"streamingS3Description": "Stream events to an S3-compatible object storage bucket. Contact support to enable this destination.",
|
"streamingS3Description": "Stream events to an S3-compatible object storage bucket. Coming soon.",
|
||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Forward events directly to your Datadog account. Contact support to enable this destination.",
|
"streamingDatadogDescription": "Forward events directly to your Datadog account. Coming soon.",
|
||||||
"streamingTypePickerDescription": "Choose a destination type to get started.",
|
"streamingTypePickerDescription": "Choose a destination type to get started.",
|
||||||
"streamingFailedToLoad": "Failed to load destinations",
|
"streamingFailedToLoad": "Failed to load destinations",
|
||||||
"streamingUnexpectedError": "An unexpected error occurred.",
|
"streamingUnexpectedError": "An unexpected error occurred.",
|
||||||
@@ -2936,7 +2849,7 @@
|
|||||||
"httpDestNamePlaceholder": "My HTTP destination",
|
"httpDestNamePlaceholder": "My HTTP destination",
|
||||||
"httpDestUrlLabel": "Destination URL",
|
"httpDestUrlLabel": "Destination URL",
|
||||||
"httpDestUrlErrorHttpRequired": "URL must use http or https",
|
"httpDestUrlErrorHttpRequired": "URL must use http or https",
|
||||||
"httpDestUrlErrorHttpsRequired": "HTTPS is required",
|
"httpDestUrlErrorHttpsRequired": "HTTPS is required on cloud deployments",
|
||||||
"httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)",
|
"httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)",
|
||||||
"httpDestAuthTitle": "Authentication",
|
"httpDestAuthTitle": "Authentication",
|
||||||
"httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.",
|
"httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.",
|
||||||
|
|||||||
136
package-lock.json
generated
@@ -44,7 +44,6 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xyflow/react": "^12.8.4",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -90,13 +89,13 @@
|
|||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.9.2",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "^1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
@@ -131,7 +130,7 @@
|
|||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sshpk": "1.17.4",
|
"@types/sshpk": "^1.17.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/topojson-client": "3.1.5",
|
"@types/topojson-client": "3.1.5",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
@@ -1059,7 +1058,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -2355,7 +2353,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2378,7 +2375,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2401,7 +2397,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2418,7 +2413,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2435,7 +2429,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2452,7 +2445,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2469,7 +2461,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2486,7 +2477,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2503,7 +2493,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2520,7 +2509,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2537,7 +2525,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2554,7 +2541,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2577,7 +2563,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2600,7 +2585,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2623,7 +2607,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2646,7 +2629,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2669,7 +2651,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2692,7 +2673,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2715,7 +2695,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2735,7 +2714,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2755,7 +2733,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2775,7 +2752,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3035,7 +3011,6 @@
|
|||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -6982,7 +6957,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -8443,7 +8417,6 @@
|
|||||||
"version": "5.90.21",
|
"version": "5.90.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.90.20"
|
||||||
},
|
},
|
||||||
@@ -8559,7 +8532,6 @@
|
|||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -8719,6 +8691,7 @@
|
|||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
@@ -8834,6 +8807,7 @@
|
|||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
"node_modules/@types/d3-shape": {
|
||||||
@@ -8868,6 +8842,7 @@
|
|||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
@@ -8877,6 +8852,7 @@
|
|||||||
"version": "3.0.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-interpolate": "*",
|
"@types/d3-interpolate": "*",
|
||||||
@@ -8903,7 +8879,6 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -8999,7 +8974,6 @@
|
|||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -9027,7 +9001,6 @@
|
|||||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"pg-protocol": "*",
|
"pg-protocol": "*",
|
||||||
@@ -9053,7 +9026,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -9064,7 +9036,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -9151,7 +9122,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
@@ -9225,7 +9197,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -9680,38 +9651,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
|
||||||
"version": "12.8.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
|
|
||||||
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@xyflow/system": "0.0.68",
|
|
||||||
"classcat": "^5.0.3",
|
|
||||||
"zustand": "^4.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=17",
|
|
||||||
"react-dom": ">=17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xyflow/system": {
|
|
||||||
"version": "0.0.68",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
|
|
||||||
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-drag": "^3.0.7",
|
|
||||||
"@types/d3-interpolate": "^3.0.4",
|
|
||||||
"@types/d3-selection": "^3.0.10",
|
|
||||||
"@types/d3-transition": "^3.0.8",
|
|
||||||
"@types/d3-zoom": "^3.0.8",
|
|
||||||
"d3-drag": "^3.0.0",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-selection": "^3.0.0",
|
|
||||||
"d3-zoom": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -9731,7 +9670,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -10180,7 +10118,6 @@
|
|||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -10252,7 +10189,6 @@
|
|||||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
@@ -10381,7 +10317,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -10564,12 +10499,6 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/classcat": {
|
|
||||||
"version": "5.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
|
||||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cli-spinners": {
|
"node_modules/cli-spinners": {
|
||||||
"version": "2.9.2",
|
"version": "2.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
||||||
@@ -11294,7 +11223,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -11735,6 +11663,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -12369,7 +12298,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -12455,7 +12383,6 @@
|
|||||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -12592,7 +12519,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -12986,7 +12912,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -15404,6 +15329,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.2.7",
|
||||||
"marked": "14.0.0"
|
"marked": "14.0.0"
|
||||||
@@ -15414,6 +15340,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
@@ -15502,7 +15429,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
||||||
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.14",
|
"@next/env": "15.5.14",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -16462,7 +16388,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@@ -16967,7 +16892,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -16999,7 +16923,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -17292,7 +17215,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -18754,8 +18676,7 @@
|
|||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -19230,7 +19151,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -19658,7 +19578,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
@@ -19865,7 +19784,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -19881,34 +19799,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "4.5.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
|
||||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"use-sync-external-store": "^1.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.7.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=16.8",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=16.8"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xyflow/react": "^12.8.4",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 765 KiB After Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 742 KiB After Width: | Height: | Size: 569 KiB |
|
Before Width: | Height: | Size: 765 KiB After Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 274 KiB |
@@ -16,13 +16,11 @@ import {
|
|||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
targets,
|
targets,
|
||||||
roles,
|
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sessions,
|
sessions,
|
||||||
clients,
|
clients,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
|
||||||
sites
|
sites
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
@@ -427,9 +425,7 @@ export const eventStreamingDestinations = pgTable(
|
|||||||
orgId: varchar("orgId", { length: 255 })
|
orgId: varchar("orgId", { length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
sendConnectionLogs: boolean("sendConnectionLogs")
|
sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false),
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
||||||
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
||||||
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
||||||
@@ -451,9 +447,7 @@ export const eventStreamingCursors = pgTable(
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection"
|
logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection"
|
||||||
lastSentId: bigint("lastSentId", { mode: "number" })
|
lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0),
|
||||||
.notNull()
|
|
||||||
.default(0),
|
|
||||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent
|
lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -464,66 +458,6 @@ export const eventStreamingCursors = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const alertRules = pgTable("alertRules", {
|
|
||||||
alertRuleId: serial("alertRuleId").primaryKey(),
|
|
||||||
orgId: varchar("orgId", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
|
||||||
// Single field encodes both source and trigger - no redundancy
|
|
||||||
eventType: varchar("eventType", { length: 100 })
|
|
||||||
.$type<
|
|
||||||
| "site_online"
|
|
||||||
| "site_offline"
|
|
||||||
| "health_check_healthy"
|
|
||||||
| "health_check_not_healthy"
|
|
||||||
>()
|
|
||||||
.notNull(),
|
|
||||||
// Nullable depending on eventType
|
|
||||||
siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }),
|
|
||||||
healthCheckId: integer("healthCheckId").references(
|
|
||||||
() => targetHealthCheck.targetHealthCheckId,
|
|
||||||
{ onDelete: "cascade" }
|
|
||||||
),
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
|
|
||||||
lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable
|
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Separating channels by type avoids the mixed-shape problem entirely
|
|
||||||
export const alertEmailActions = pgTable("alertEmailActions", {
|
|
||||||
emailActionId: serial("emailActionId").primaryKey(),
|
|
||||||
alertRuleId: integer("alertRuleId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable
|
|
||||||
});
|
|
||||||
|
|
||||||
export const alertEmailRecipients = pgTable("alertEmailRecipients", {
|
|
||||||
recipientId: serial("recipientId").primaryKey(),
|
|
||||||
emailActionId: integer("emailActionId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
|
|
||||||
// At least one of these should be set - enforced at app level
|
|
||||||
userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }),
|
|
||||||
roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
|
|
||||||
email: varchar("email", { length: 255 }) // external emails not tied to a user
|
|
||||||
});
|
|
||||||
|
|
||||||
export const alertWebhookActions = pgTable("alertWebhookActions", {
|
|
||||||
webhookActionId: serial("webhookActionId").primaryKey(),
|
|
||||||
alertRuleId: integer("alertRuleId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
|
||||||
webhookUrl: text("webhookUrl").notNull(),
|
|
||||||
config: text("config"), // encrypted JSON with auth config (authType, credentials)
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -1080,7 +1080,6 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
|||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
|
|
||||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||||
export type Idp = InferSelectModel<typeof idp>;
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import {
|
|||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
orgs,
|
orgs,
|
||||||
roles,
|
|
||||||
sessions,
|
sessions,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
targetHealthCheck,
|
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
@@ -457,62 +455,6 @@ export const eventStreamingCursors = sqliteTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const alertRules = sqliteTable("alertRules", {
|
|
||||||
alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
eventType: text("eventType")
|
|
||||||
.$type<
|
|
||||||
| "site_online"
|
|
||||||
| "site_offline"
|
|
||||||
| "health_check_healthy"
|
|
||||||
| "health_check_not_healthy"
|
|
||||||
>()
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }),
|
|
||||||
healthCheckId: integer("healthCheckId").references(
|
|
||||||
() => targetHealthCheck.targetHealthCheckId,
|
|
||||||
{ onDelete: "cascade" }
|
|
||||||
),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
||||||
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
|
|
||||||
lastTriggeredAt: integer("lastTriggeredAt"),
|
|
||||||
createdAt: integer("createdAt").notNull(),
|
|
||||||
updatedAt: integer("updatedAt").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const alertEmailActions = sqliteTable("alertEmailActions", {
|
|
||||||
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
|
|
||||||
alertRuleId: integer("alertRuleId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
||||||
lastSentAt: integer("lastSentAt")
|
|
||||||
});
|
|
||||||
|
|
||||||
export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
|
|
||||||
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
|
|
||||||
emailActionId: integer("emailActionId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
|
|
||||||
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
|
|
||||||
roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
|
|
||||||
email: text("email")
|
|
||||||
});
|
|
||||||
|
|
||||||
export const alertWebhookActions = sqliteTable("alertWebhookActions", {
|
|
||||||
webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
|
|
||||||
alertRuleId: integer("alertRuleId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
|
||||||
webhookUrl: text("webhookUrl").notNull(),
|
|
||||||
config: text("config"), // encrypted JSON with auth config (authType, credentials)
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
||||||
lastSentAt: integer("lastSentAt")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
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 = {
|
export const licensePriceSet: LicensePriceSet = {
|
||||||
// Free license matches the freeLimitSet
|
// Free license matches the freeLimitSet
|
||||||
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
|
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
|
||||||
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
|
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const licensePriceSetSandbox: LicensePriceSet = {
|
export const licensePriceSetSandbox: LicensePriceSet = {
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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";
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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]
|
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
|
||||||
) {
|
) {
|
||||||
numUsers = 50;
|
numUsers = 50;
|
||||||
numSites = 100;
|
numSites = 50;
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
||||||
|
|||||||
@@ -29,9 +29,65 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
|
async function getLatestOlmVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/olm/tags",
|
||||||
|
{
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await response.json();
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
logger.warn("No tags found for Olm repository");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
|
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||||
|
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
logger.warn("Connection timeout while fetching latest Olm version");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching latest Olm version:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listClientsParamsSchema = z.strictObject({
|
const listClientsParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
@@ -357,45 +413,44 @@ export async function listClients(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
|
const latestOlVersionPromise = getLatestOlmVersion();
|
||||||
// const latestOlmVersionPromise = getLatestOlmVersion();
|
|
||||||
|
|
||||||
// const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
|
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
|
||||||
// (client) => {
|
(client) => {
|
||||||
// const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
|
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
|
||||||
// // Initially set to false, will be updated if version check succeeds
|
// Initially set to false, will be updated if version check succeeds
|
||||||
// OlmWithUpdate.olmUpdateAvailable = false;
|
OlmWithUpdate.olmUpdateAvailable = false;
|
||||||
// return OlmWithUpdate;
|
return OlmWithUpdate;
|
||||||
// }
|
}
|
||||||
// );
|
);
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
// try {
|
try {
|
||||||
// const latestOlmVersion = await latestOlVersionPromise;
|
const latestOlVersion = await latestOlVersionPromise;
|
||||||
|
|
||||||
// if (latestOlVersion) {
|
if (latestOlVersion) {
|
||||||
// olmsWithUpdates.forEach((client) => {
|
olmsWithUpdates.forEach((client) => {
|
||||||
// try {
|
try {
|
||||||
// client.olmUpdateAvailable = semver.lt(
|
client.olmUpdateAvailable = semver.lt(
|
||||||
// client.olmVersion ? client.olmVersion : "",
|
client.olmVersion ? client.olmVersion : "",
|
||||||
// latestOlVersion
|
latestOlVersion
|
||||||
// );
|
);
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// client.olmUpdateAvailable = false;
|
client.olmUpdateAvailable = false;
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// // Log the error but don't let it block the response
|
// Log the error but don't let it block the response
|
||||||
// logger.warn(
|
logger.warn(
|
||||||
// "Failed to check for OLM updates, continuing without update info:",
|
"Failed to check for OLM updates, continuing without update info:",
|
||||||
// error
|
error
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
|
||||||
return response<ListClientsResponse>(res, {
|
return response<ListClientsResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
clients: clientsWithSites,
|
clients: olmsWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -30,10 +30,65 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
|
async function getLatestOlmVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/olm/tags",
|
||||||
|
{
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await response.json();
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
logger.warn("No tags found for Olm repository");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
|
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||||
|
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
logger.warn("Connection timeout while fetching latest Olm version");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching latest Olm version:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listUserDevicesParamsSchema = z.strictObject({
|
const listUserDevicesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
@@ -398,30 +453,29 @@ export async function listUserDevices(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
|
// Try to get the latest version, but don't block if it fails
|
||||||
// // Try to get the latest version, but don't block if it fails
|
try {
|
||||||
// try {
|
const latestOlmVersion = await getLatestOlmVersion();
|
||||||
// const latestOlmVersion = await getLatestOlmVersion();
|
|
||||||
|
|
||||||
// if (latestOlmVersion) {
|
if (latestOlmVersion) {
|
||||||
// olmsWithUpdates.forEach((client) => {
|
olmsWithUpdates.forEach((client) => {
|
||||||
// try {
|
try {
|
||||||
// client.olmUpdateAvailable = semver.lt(
|
client.olmUpdateAvailable = semver.lt(
|
||||||
// client.olmVersion ? client.olmVersion : "",
|
client.olmVersion ? client.olmVersion : "",
|
||||||
// latestOlmVersion
|
latestOlmVersion
|
||||||
// );
|
);
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// client.olmUpdateAvailable = false;
|
client.olmUpdateAvailable = false;
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// // Log the error but don't let it block the response
|
// Log the error but don't let it block the response
|
||||||
// logger.warn(
|
logger.warn(
|
||||||
// "Failed to check for OLM updates, continuing without update info:",
|
"Failed to check for OLM updates, continuing without update info:",
|
||||||
// error
|
error
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
|
||||||
return response<ListUserDevicesResponse>(res, {
|
return response<ListUserDevicesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ import semver from "semver";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
|
||||||
// a transient network failure / timeout does not flip every site back to
|
|
||||||
// newtUpdateAvailable: false.
|
|
||||||
let staleNewtVersion: string | null = null;
|
|
||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const cachedVersion = await cache.get<string>("latestNewtVersion");
|
const cachedVersion = await cache.get<string>("latestNewtVersion");
|
||||||
@@ -34,7 +29,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://api.github.com/repos/fosrl/newt/tags",
|
"https://api.github.com/repos/fosrl/newt/tags",
|
||||||
@@ -49,46 +44,18 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
|
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
|
||||||
);
|
);
|
||||||
return staleNewtVersion;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tags = await response.json();
|
let tags = await response.json();
|
||||||
if (!Array.isArray(tags) || tags.length === 0) {
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
logger.warn("No tags found for Newt repository");
|
logger.warn("No tags found for Newt repository");
|
||||||
return staleNewtVersion;
|
return null;
|
||||||
}
|
}
|
||||||
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
// Remove release-candidates, then sort descending by semver so that
|
|
||||||
// duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks
|
|
||||||
// from the GitHub API do not cause an older tag to be selected.
|
|
||||||
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
|
|
||||||
tags.sort((a: any, b: any) => {
|
|
||||||
const va = semver.coerce(a.name);
|
|
||||||
const vb = semver.coerce(b.name);
|
|
||||||
if (!va && !vb) return 0;
|
|
||||||
if (!va) return 1;
|
|
||||||
if (!vb) return -1;
|
|
||||||
return semver.rcompare(va, vb);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deduplicate: keep only the first (highest) entry per normalised version
|
|
||||||
const seen = new Set<string>();
|
|
||||||
tags = tags.filter((tag: any) => {
|
|
||||||
const normalised = semver.coerce(tag.name)?.version;
|
|
||||||
if (!normalised || seen.has(normalised)) return false;
|
|
||||||
seen.add(normalised);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tags.length === 0) {
|
|
||||||
logger.warn("No valid semver tags found for Newt repository");
|
|
||||||
return staleNewtVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestVersion = tags[0].name;
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
staleNewtVersion = latestVersion;
|
await cache.set("latestNewtVersion", latestVersion, 3600);
|
||||||
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
|
|
||||||
|
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -106,7 +73,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
|||||||
error.message || error
|
error.message || error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return staleNewtVersion;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,8 +340,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api
|
const res = await api.post<AxiosResponse<InviteUserResponse>>(
|
||||||
.post<AxiosResponse<InviteUserResponse>>(
|
|
||||||
`/org/${orgId}/create-invite`,
|
`/org/${orgId}/create-invite`,
|
||||||
{
|
{
|
||||||
email: values.email,
|
email: values.email,
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
"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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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,7 +2,6 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
|||||||
import { Env } from "@app/lib/types/env";
|
import { Env } from "@app/lib/types/env";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
BellRing,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Cable,
|
Cable,
|
||||||
@@ -226,11 +225,6 @@ export const orgNavSections = (
|
|||||||
title: "sidebarBluePrints",
|
title: "sidebarBluePrints",
|
||||||
href: "/{orgId}/settings/blueprints",
|
href: "/{orgId}/settings/blueprints",
|
||||||
icon: <ReceiptText className="size-4 flex-none" />
|
icon: <ReceiptText className="size-4 flex-none" />
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "sidebarAlerting",
|
|
||||||
href: "/{orgId}/settings/alerting",
|
|
||||||
icon: <BellRing className="size-4 flex-none" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
"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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
"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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,715 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||