mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 06:26:39 +00:00
Compare commits
2 Commits
provisioni
...
alerting-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cce6e0820 | ||
|
|
2841c5ed4e |
@@ -331,11 +331,6 @@
|
||||
"provisioningKeysTitle": "Provisioning Key",
|
||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||
"provisioningManage": "Provisioning",
|
||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||
"pendingSites": "Pending Sites",
|
||||
"siteApproveSuccess": "Site approved successfully",
|
||||
"siteApproveError": "Error approving site",
|
||||
"provisioningKeys": "Provisioning Keys",
|
||||
"searchProvisioningKeys": "Search provisioning keys...",
|
||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||
@@ -365,17 +360,9 @@
|
||||
"provisioningKeysNeverUsed": "Never",
|
||||
"provisioningKeysEdit": "Edit Provisioning Key",
|
||||
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
|
||||
"provisioningKeysApproveNewSites": "Approve new sites",
|
||||
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
|
||||
"provisioningKeysUpdateError": "Error updating provisioning key",
|
||||
"provisioningKeysUpdated": "Provisioning key updated",
|
||||
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
|
||||
"provisioningKeysBannerTitle": "Site Provisioning Keys",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerButtonText": "Learn More",
|
||||
"pendingSitesBannerTitle": "Pending Sites",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.",
|
||||
"pendingSitesBannerButtonText": "Learn More",
|
||||
"apiKeysSettings": "{apiKeyName} Settings",
|
||||
"userTitle": "Manage All Users",
|
||||
"userDescription": "View and manage all users in the system",
|
||||
@@ -1250,6 +1237,7 @@
|
||||
"actionViewLogs": "View Logs",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"search": "Search…",
|
||||
"searchPlaceholder": "Search...",
|
||||
"emptySearchOptions": "No options found",
|
||||
"create": "Create",
|
||||
@@ -1334,10 +1322,96 @@
|
||||
"sidebarGeneral": "Manage",
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Blueprints",
|
||||
"sidebarAlerting": "Alerting",
|
||||
"sidebarOrganization": "Organization",
|
||||
"sidebarManagement": "Management",
|
||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||
"sidebarLogsAnalytics": "Analytics",
|
||||
"alertingTitle": "Alerting rules",
|
||||
"alertingDescription": "Define sources, triggers, and actions for notifications.",
|
||||
"alertingRules": "Alert rules",
|
||||
"alertingSearchRules": "Search rules…",
|
||||
"alertingAddRule": "Create Rule",
|
||||
"alertingColumnSource": "Source",
|
||||
"alertingColumnTrigger": "Trigger",
|
||||
"alertingColumnActions": "Actions",
|
||||
"alertingColumnEnabled": "Enabled",
|
||||
"alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.",
|
||||
"alertingDeleteRule": "Delete alert rule",
|
||||
"alertingRuleDeleted": "Alert rule deleted",
|
||||
"alertingRuleSaved": "Alert rule saved",
|
||||
"alertingEditRule": "Edit alert rule",
|
||||
"alertingCreateRule": "Create alert rule",
|
||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
|
||||
"alertingRuleNamePlaceholder": "Production site down",
|
||||
"alertingRuleEnabled": "Rule enabled",
|
||||
"alertingSectionSource": "Source",
|
||||
"alertingSourceType": "Source type",
|
||||
"alertingSourceSite": "Site",
|
||||
"alertingSourceHealthCheck": "Health check",
|
||||
"alertingPickSites": "Sites",
|
||||
"alertingPickHealthChecks": "Health checks",
|
||||
"alertingPickResources": "Resources",
|
||||
"alertingSelectResources": "Select resources…",
|
||||
"alertingResourcesSelected": "{count} resources selected",
|
||||
"alertingResourcesEmpty": "No resources with targets in the first 10 results.",
|
||||
"alertingSectionTrigger": "Trigger",
|
||||
"alertingTrigger": "When to alert",
|
||||
"alertingTriggerSiteOnline": "Site online",
|
||||
"alertingTriggerSiteOffline": "Site offline",
|
||||
"alertingTriggerHcHealthy": "Health check healthy",
|
||||
"alertingTriggerHcUnhealthy": "Health check unhealthy",
|
||||
"alertingSectionActions": "Actions",
|
||||
"alertingAddAction": "Add action",
|
||||
"alertingActionNotify": "Notify",
|
||||
"alertingActionSms": "SMS",
|
||||
"alertingActionWebhook": "Webhook",
|
||||
"alertingActionType": "Action type",
|
||||
"alertingNotifyUsers": "Users",
|
||||
"alertingNotifyRoles": "Roles",
|
||||
"alertingNotifyEmails": "Email addresses",
|
||||
"alertingEmailPlaceholder": "Add email and press Enter",
|
||||
"alertingSmsNumbers": "Phone numbers",
|
||||
"alertingSmsPlaceholder": "Add number and press Enter",
|
||||
"alertingWebhookMethod": "HTTP method",
|
||||
"alertingWebhookSecret": "Signing secret (optional)",
|
||||
"alertingWebhookSecretPlaceholder": "HMAC secret",
|
||||
"alertingWebhookHeaders": "Headers",
|
||||
"alertingAddHeader": "Add header",
|
||||
"alertingSelectSites": "Select sites…",
|
||||
"alertingSitesSelected": "{count} sites selected",
|
||||
"alertingSelectHealthChecks": "Select health checks…",
|
||||
"alertingHealthChecksSelected": "{count} health checks selected",
|
||||
"alertingNoHealthChecks": "No targets with health checks enabled",
|
||||
"alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.",
|
||||
"alertingSelectUsers": "Select users…",
|
||||
"alertingUsersSelected": "{count} users selected",
|
||||
"alertingSelectRoles": "Select roles…",
|
||||
"alertingRolesSelected": "{count} roles selected",
|
||||
"alertingSummarySites": "Sites ({count})",
|
||||
"alertingSummaryHealthChecks": "Health checks ({count})",
|
||||
"alertingErrorNameRequired": "Enter a name",
|
||||
"alertingErrorActionsMin": "Add at least one action",
|
||||
"alertingErrorPickSites": "Select at least one site",
|
||||
"alertingErrorPickHealthChecks": "Select at least one health check",
|
||||
"alertingErrorTriggerSite": "Choose a site trigger",
|
||||
"alertingErrorTriggerHealth": "Choose a health check trigger",
|
||||
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
|
||||
"alertingErrorSmsPhones": "Add at least one phone number",
|
||||
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
|
||||
"alertingConfigureSource": "Configure Source",
|
||||
"alertingConfigureTrigger": "Configure Trigger",
|
||||
"alertingConfigureActions": "Configure Actions",
|
||||
"alertingBackToRules": "Back to Rules",
|
||||
"alertingDraftBadge": "Draft — save to store this rule",
|
||||
"alertingSidebarHint": "Click a step on the canvas to edit it here.",
|
||||
"alertingGraphCanvasTitle": "Rule Flow",
|
||||
"alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.",
|
||||
"alertingNodeNotConfigured": "Not configured yet",
|
||||
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
|
||||
"alertingNodeRoleSource": "Source",
|
||||
"alertingNodeRoleTrigger": "Trigger",
|
||||
"alertingNodeRoleAction": "Action",
|
||||
"blueprints": "Blueprints",
|
||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||
"blueprintAdd": "Add Blueprint",
|
||||
|
||||
130
package-lock.json
generated
130
package-lock.json
generated
@@ -44,6 +44,7 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
@@ -1058,6 +1059,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2353,6 +2355,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2375,6 +2378,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2397,6 +2401,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2413,6 +2418,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2429,6 +2435,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2445,6 +2452,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2461,6 +2469,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2477,6 +2486,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2493,6 +2503,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2509,6 +2520,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2525,6 +2537,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2541,6 +2554,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2563,6 +2577,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2585,6 +2600,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2607,6 +2623,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2629,6 +2646,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2651,6 +2669,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2673,6 +2692,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2695,6 +2715,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2714,6 +2735,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2733,6 +2755,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2752,6 +2775,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3002,6 +3026,7 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -6948,6 +6973,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -8408,6 +8434,7 @@
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -8523,6 +8550,7 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -8682,7 +8710,6 @@
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
@@ -8798,7 +8825,6 @@
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
@@ -8833,7 +8859,6 @@
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
@@ -8843,7 +8868,6 @@
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
@@ -8870,6 +8894,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -8965,6 +8990,7 @@
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -8992,6 +9018,7 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -9017,6 +9044,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9027,6 +9055,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -9113,8 +9142,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
@@ -9188,6 +9216,7 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -9642,6 +9671,38 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
|
||||
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.68",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
|
||||
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -9661,6 +9722,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -10109,6 +10171,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -10180,6 +10243,7 @@
|
||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -10308,6 +10372,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -10490,6 +10555,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-spinners": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
||||
@@ -11214,6 +11285,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -11654,7 +11726,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -12289,6 +12360,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -12374,6 +12446,7 @@
|
||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -12510,6 +12583,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -12903,6 +12977,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -15320,7 +15395,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -15331,7 +15405,6 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -15419,6 +15492,7 @@
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
||||
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.12",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -16377,6 +16451,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -16881,6 +16956,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -16912,6 +16988,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -17204,6 +17281,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -18665,7 +18743,8 @@
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -19140,6 +19219,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -19567,6 +19647,7 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -19773,6 +19854,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -19788,6 +19870,34 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
|
||||
@@ -391,8 +391,7 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
|
||||
lastUsed: varchar("lastUsed", { length: 255 }),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: varchar("validUntil", { length: 255 }),
|
||||
approveNewSites: boolean("approveNewSites").notNull().default(true)
|
||||
validUntil: varchar("validUntil", { length: 255 })
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = pgTable(
|
||||
|
||||
@@ -100,8 +100,7 @@ export const sites = pgTable("sites", {
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
status: varchar("status").$type<"pending" | "approved">()
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
|
||||
@@ -375,10 +375,7 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
|
||||
lastUsed: text("lastUsed"),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: text("validUntil"),
|
||||
approveNewSites: integer("approveNewSites", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
validUntil: text("validUntil")
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = sqliteTable(
|
||||
|
||||
@@ -110,8 +110,7 @@ export const sites = sqliteTable("sites", {
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
status: text("status").$type<"pending" | "approved">()
|
||||
.default(true)
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
|
||||
@@ -38,8 +38,7 @@ const bodySchema = z
|
||||
z.null(),
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
]),
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional().default(true)
|
||||
validUntil: z.string().max(255).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -83,7 +82,7 @@ export async function createSiteProvisioningKey(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { name, maxBatchSize, approveNewSites } = parsedBody.data;
|
||||
const { name, maxBatchSize } = parsedBody.data;
|
||||
const vuRaw = parsedBody.data.validUntil;
|
||||
const validUntil =
|
||||
vuRaw == null || vuRaw.trim() === ""
|
||||
@@ -107,8 +106,7 @@ export async function createSiteProvisioningKey(
|
||||
lastUsed: null,
|
||||
maxBatchSize,
|
||||
numUsed: 0,
|
||||
validUntil,
|
||||
approveNewSites
|
||||
validUntil
|
||||
});
|
||||
|
||||
await trx.insert(siteProvisioningKeyOrg).values({
|
||||
@@ -129,8 +127,7 @@ export async function createSiteProvisioningKey(
|
||||
lastUsed: null,
|
||||
maxBatchSize,
|
||||
numUsed: 0,
|
||||
validUntil,
|
||||
approveNewSites
|
||||
validUntil
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -57,8 +57,7 @@ function querySiteProvisioningKeys(orgId: string) {
|
||||
lastUsed: siteProvisioningKeys.lastUsed,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
})
|
||||
.from(siteProvisioningKeyOrg)
|
||||
.innerJoin(
|
||||
|
||||
@@ -39,18 +39,16 @@ const bodySchema = z
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
])
|
||||
.optional(),
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional()
|
||||
validUntil: z.string().max(255).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.maxBatchSize === undefined &&
|
||||
data.validUntil === undefined &&
|
||||
data.approveNewSites === undefined
|
||||
data.validUntil === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites",
|
||||
message: "Provide maxBatchSize and/or validUntil",
|
||||
path: ["maxBatchSize"]
|
||||
});
|
||||
}
|
||||
@@ -131,7 +129,6 @@ export async function updateSiteProvisioningKey(
|
||||
const setValues: {
|
||||
maxBatchSize?: number | null;
|
||||
validUntil?: string | null;
|
||||
approveNewSites?: boolean;
|
||||
} = {};
|
||||
if (body.maxBatchSize !== undefined) {
|
||||
setValues.maxBatchSize = body.maxBatchSize;
|
||||
@@ -142,9 +139,6 @@ export async function updateSiteProvisioningKey(
|
||||
? null
|
||||
: new Date(Date.parse(body.validUntil)).toISOString();
|
||||
}
|
||||
if (body.approveNewSites !== undefined) {
|
||||
setValues.approveNewSites = body.approveNewSites;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(siteProvisioningKeys)
|
||||
@@ -166,8 +160,7 @@ export async function updateSiteProvisioningKey(
|
||||
lastUsed: siteProvisioningKeys.lastUsed,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
})
|
||||
.from(siteProvisioningKeys)
|
||||
.where(
|
||||
|
||||
@@ -82,8 +82,7 @@ export async function registerNewt(
|
||||
orgId: siteProvisioningKeyOrg.orgId,
|
||||
maxBatchSize: siteProvisioningKeys.maxBatchSize,
|
||||
numUsed: siteProvisioningKeys.numUsed,
|
||||
validUntil: siteProvisioningKeys.validUntil,
|
||||
approveNewSites: siteProvisioningKeys.approveNewSites,
|
||||
validUntil: siteProvisioningKeys.validUntil
|
||||
})
|
||||
.from(siteProvisioningKeys)
|
||||
.innerJoin(
|
||||
@@ -197,8 +196,7 @@ export async function registerNewt(
|
||||
name: niceId,
|
||||
niceId,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites ? "approved" : "pending",
|
||||
dockerSocketEnabled: true
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -298,8 +298,7 @@ export async function createSite(
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
type,
|
||||
dockerSocketEnabled: true,
|
||||
status: "approved"
|
||||
dockerSocketEnabled: true
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "wireguard") {
|
||||
@@ -356,8 +355,7 @@ export async function createSite(
|
||||
niceId,
|
||||
subnet,
|
||||
type,
|
||||
pubKey: pubKey || null,
|
||||
status: "approved"
|
||||
pubKey: pubKey || null
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "local") {
|
||||
@@ -372,8 +370,7 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
subnet: "0.0.0.0/32",
|
||||
status: "approved"
|
||||
subnet: "0.0.0.0/32"
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
|
||||
@@ -135,15 +135,6 @@ const listSitesSchema = z.object({
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site status"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -165,8 +156,7 @@ function querySitesBase() {
|
||||
exitNodeId: sites.exitNodeId,
|
||||
exitNodeName: exitNodes.name,
|
||||
exitNodeEndpoint: exitNodes.endpoint,
|
||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
|
||||
status: sites.status
|
||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
@@ -255,7 +245,7 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
const { pageSize, page, query, sort_by, order, online } =
|
||||
parsedQuery.data;
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
@@ -283,9 +273,6 @@ export async function listSites(
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ const updateSiteBodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
status: z.enum(["pending", "approved"]).optional(),
|
||||
dockerSocketEnabled: z.boolean().optional()
|
||||
// remoteSubnets: z.string().optional()
|
||||
// subdomain: z
|
||||
// .string()
|
||||
|
||||
@@ -8,7 +8,6 @@ export type SiteProvisioningKeyListItem = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
export type ListSiteProvisioningKeysResponse = {
|
||||
@@ -27,7 +26,6 @@ export type CreateSiteProvisioningKeyResponse = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
export type UpdateSiteProvisioningKeyResponse = {
|
||||
@@ -40,5 +38,4 @@ export type UpdateSiteProvisioningKeyResponse = {
|
||||
maxBatchSize: number | null;
|
||||
numUsed: number;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
@@ -340,15 +340,16 @@ export default function Page() {
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleIds,
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail
|
||||
}
|
||||
)
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleIds,
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
|
||||
52
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
52
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||
import { ruleToFormValues } from "@app/lib/alertRuleForm";
|
||||
import type { AlertRule } from "@app/lib/alertRulesLocalStorage";
|
||||
import { getRule } from "@app/lib/alertRulesLocalStorage";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function EditAlertRulePage() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const orgId = params.orgId as string;
|
||||
const ruleId = params.ruleId as string;
|
||||
const [rule, setRule] = useState<AlertRule | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const r = getRule(orgId, ruleId);
|
||||
setRule(r ?? null);
|
||||
}, [orgId, ruleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rule === null) {
|
||||
router.replace(`/${orgId}/settings/alerting`);
|
||||
}
|
||||
}, [rule, orgId, router]);
|
||||
|
||||
if (rule === undefined) {
|
||||
return (
|
||||
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rule === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertRuleGraphEditor
|
||||
key={rule.id}
|
||||
orgId={orgId}
|
||||
ruleId={rule.id}
|
||||
createdAt={rule.createdAt}
|
||||
initialValues={ruleToFormValues(rule)}
|
||||
isNew={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
40
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||
import { defaultFormValues } from "@app/lib/alertRuleForm";
|
||||
import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function NewAlertRulePage() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const orgId = params.orgId as string;
|
||||
const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMeta({ id: newRuleId(), createdAt: isoNow() });
|
||||
}, []);
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertRuleGraphEditor
|
||||
key={meta.id}
|
||||
orgId={orgId}
|
||||
ruleId={meta.id}
|
||||
createdAt={meta.createdAt}
|
||||
initialValues={defaultFormValues()}
|
||||
isNew
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/app/[orgId]/settings/alerting/page.tsx
Normal file
24
src/app/[orgId]/settings/alerting/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import AlertingRulesTable from "@app/components/AlertingRulesTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type AlertingPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AlertingPage(props: AlertingPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("alertingTitle")}
|
||||
description={t("alertingDescription")}
|
||||
/>
|
||||
<AlertingRulesTable orgId={params.orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SiteProvisioningKeysTable, {
|
||||
SiteProvisioningKeyRow
|
||||
} from "../../../../../components/SiteProvisioningKeysTable";
|
||||
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Plug } from "lucide-react";
|
||||
|
||||
type ProvisioningKeysPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProvisioningKeysPage(
|
||||
props: ProvisioningKeysPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
|
||||
[];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListSiteProvisioningKeysResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/site-provisioning-keys`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
|
||||
} catch (e) {}
|
||||
|
||||
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
|
||||
name: k.name,
|
||||
id: k.siteProvisioningKeyId,
|
||||
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
|
||||
createdAt: k.createdAt,
|
||||
lastUsed: k.lastUsed,
|
||||
maxBatchSize: k.maxBatchSize,
|
||||
numUsed: k.numUsed,
|
||||
validUntil: k.validUntil
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DismissableBanner
|
||||
storageKey="sites-banner-dismissed"
|
||||
version={1}
|
||||
title={t("provisioningKeysBannerTitle")}
|
||||
titleIcon={<Plug className="w-5 h-5 text-primary" />}
|
||||
description={t("provisioningKeysBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("provisioningKeysBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
|
||||
/>
|
||||
|
||||
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface ProvisioningLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function ProvisioningLayout({
|
||||
children,
|
||||
params
|
||||
}: ProvisioningLayoutProps) {
|
||||
const { orgId } = await params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("provisioningKeys"),
|
||||
href: `/${orgId}/settings/provisioning/keys`
|
||||
},
|
||||
{
|
||||
title: t("pendingSites"),
|
||||
href: `/${orgId}/settings/provisioning/pending`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("provisioningManage")}
|
||||
description={t("provisioningDescription")}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,60 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SiteProvisioningKeysTable, {
|
||||
SiteProvisioningKeyRow
|
||||
} from "../../../../components/SiteProvisioningKeysTable";
|
||||
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type ProvisioningPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProvisioningPage(props: ProvisioningPageProps) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/provisioning/keys`);
|
||||
}
|
||||
const t = await getTranslations();
|
||||
|
||||
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
|
||||
[];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListSiteProvisioningKeysResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/site-provisioning-keys`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
|
||||
} catch (e) {}
|
||||
|
||||
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
|
||||
name: k.name,
|
||||
id: k.siteProvisioningKeyId,
|
||||
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
|
||||
createdAt: k.createdAt,
|
||||
lastUsed: k.lastUsed,
|
||||
maxBatchSize: k.maxBatchSize,
|
||||
numUsed: k.numUsed,
|
||||
validUntil: k.validUntil
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("provisioningKeysManage")}
|
||||
description={t("provisioningKeysDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
|
||||
/>
|
||||
|
||||
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { SiteRow } from "@app/components/SitesTable";
|
||||
import PendingSitesTable from "@app/components/PendingSitesTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Plug } from "lucide-react";
|
||||
|
||||
type PendingSitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PendingSitesPage(props: PendingSitesPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const incomingSearchParams = new URLSearchParams(await props.searchParams);
|
||||
incomingSearchParams.set("status", "pending");
|
||||
|
||||
let sites: ListSitesResponse["sites"] = [];
|
||||
let pagination: ListSitesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${params.orgId}/sites?${incomingSearchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
sites = responseData.sites;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
function formatSize(mb: number, type: string): string {
|
||||
if (type === "local") {
|
||||
return "-";
|
||||
}
|
||||
if (mb >= 1024 * 1024) {
|
||||
return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) });
|
||||
} else if (mb >= 1024) {
|
||||
return t("gigabytes", { count: (mb / 1024).toFixed(2) });
|
||||
} else {
|
||||
return t("megabytes", { count: mb.toFixed(2) });
|
||||
}
|
||||
}
|
||||
|
||||
const siteRows: SiteRow[] = sites.map((site) => ({
|
||||
name: site.name,
|
||||
id: site.siteId,
|
||||
nice: site.niceId.toString(),
|
||||
address: site.address?.split("/")[0],
|
||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||
mbOut: formatSize(site.megabytesOut || 0, site.type),
|
||||
orgId: params.orgId,
|
||||
type: site.type as any,
|
||||
online: site.online,
|
||||
newtVersion: site.newtVersion || undefined,
|
||||
newtUpdateAvailable: site.newtUpdateAvailable || false,
|
||||
exitNodeName: site.exitNodeName || undefined,
|
||||
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
|
||||
remoteExitNodeId: (site as any).remoteExitNodeId || undefined
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DismissableBanner
|
||||
storageKey="sites-banner-dismissed"
|
||||
version={1}
|
||||
title={t("pendingSitesBannerTitle")}
|
||||
titleIcon={<Plug className="w-5 h-5 text-primary" />}
|
||||
description={t("pendingSitesBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("pendingSitesBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
<PendingSitesTable
|
||||
sites={siteRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let sites: ListSitesResponse["sites"] = [];
|
||||
let pagination: ListSitesResponse["pagination"] = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Boxes,
|
||||
Building2,
|
||||
Cable,
|
||||
@@ -219,6 +220,11 @@ export const orgNavSections = (
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarAlerting",
|
||||
href: "/{orgId}/settings/alerting",
|
||||
icon: <BellRing className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
282
src/components/AlertingRulesTable.tsx
Normal file
282
src/components/AlertingRulesTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
type AlertRule,
|
||||
deleteRule,
|
||||
isoNow,
|
||||
loadRules,
|
||||
upsertRule
|
||||
} from "@app/lib/alertRulesLocalStorage";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
type AlertingRulesTableProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
function ruleHref(orgId: string, ruleId: string) {
|
||||
return `/${orgId}/settings/alerting/${ruleId}`;
|
||||
}
|
||||
|
||||
function sourceSummary(
|
||||
rule: AlertRule,
|
||||
t: (k: string, o?: Record<string, number | string>) => string
|
||||
) {
|
||||
if (rule.source.type === "site") {
|
||||
return t("alertingSummarySites", {
|
||||
count: rule.source.siteIds.length
|
||||
});
|
||||
}
|
||||
return t("alertingSummaryHealthChecks", {
|
||||
count: rule.source.targetIds.length
|
||||
});
|
||||
}
|
||||
|
||||
function triggerLabel(rule: AlertRule, t: (k: string) => string) {
|
||||
switch (rule.trigger) {
|
||||
case "site_online":
|
||||
return t("alertingTriggerSiteOnline");
|
||||
case "site_offline":
|
||||
return t("alertingTriggerSiteOffline");
|
||||
case "health_check_healthy":
|
||||
return t("alertingTriggerHcHealthy");
|
||||
case "health_check_unhealthy":
|
||||
return t("alertingTriggerHcUnhealthy");
|
||||
default:
|
||||
return rule.trigger;
|
||||
}
|
||||
}
|
||||
|
||||
function actionBadges(rule: AlertRule, t: (k: string) => string) {
|
||||
return rule.actions.map((a, i) => {
|
||||
if (a.type === "notify") {
|
||||
return (
|
||||
<Badge key={`notify-${i}`} variant="secondary">
|
||||
{t("alertingActionNotify")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (a.type === "sms") {
|
||||
return (
|
||||
<Badge key={`sms-${i}`} variant="secondary">
|
||||
{t("alertingActionSms")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge key={`webhook-${i}`} variant="secondary">
|
||||
{t("alertingActionWebhook")}
|
||||
</Badge>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [rows, setRows] = useState<AlertRule[]>([]);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<AlertRule | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const refreshFromStorage = useCallback(() => {
|
||||
setRows(loadRules(orgId));
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFromStorage();
|
||||
}, [refreshFromStorage]);
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
refreshFromStorage();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setEnabled = (rule: AlertRule, enabled: boolean) => {
|
||||
upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() });
|
||||
refreshFromStorage();
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selected) return;
|
||||
deleteRule(orgId, selected.id);
|
||||
refreshFromStorage();
|
||||
setDeleteOpen(false);
|
||||
setSelected(null);
|
||||
toast({ title: t("alertingRuleDeleted") });
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<AlertRule>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
friendlyName: t("alertingColumnSource"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnSource")}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{sourceSummary(row.original, t)}</span>
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
friendlyName: t("alertingColumnTrigger"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnTrigger")}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{triggerLabel(row.original, t)}</span>
|
||||
},
|
||||
{
|
||||
id: "actionsCol",
|
||||
friendlyName: t("alertingColumnActions"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnActions")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-[14rem]">
|
||||
{actionBadges(row.original, t)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("alertingColumnEnabled"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnEnabled")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
onCheckedChange={(v) => setEnabled(r, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{moment(row.original.createdAt).format("lll")}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "rowActions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={ruleHref(orgId, r.id)}>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
setOpen={(val) => {
|
||||
setDeleteOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("alertingDeleteQuestion")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("delete")}
|
||||
onConfirm={confirmDelete}
|
||||
string={selected.name}
|
||||
title={t("alertingDeleteRule")}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
persistPageSize="Org-alerting-rules-table"
|
||||
title={t("alertingRules")}
|
||||
searchPlaceholder={t("alertingSearchRules")}
|
||||
searchColumn="name"
|
||||
onAdd={() => {
|
||||
router.push(`/${orgId}/settings/alerting/create`);
|
||||
}}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t("alertingAddRule")}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="rowActions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +79,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
.max(1_000_000, {
|
||||
message: t("provisioningKeysMaxBatchSizeInvalid")
|
||||
}),
|
||||
validUntil: z.string().optional(),
|
||||
approveNewSites: z.boolean()
|
||||
validUntil: z.string().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -104,8 +103,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
validUntil: ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,8 +114,7 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
validUntil: ""
|
||||
});
|
||||
}
|
||||
}, [open, form]);
|
||||
@@ -126,21 +123,18 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateSiteProvisioningKeyResponse>>(
|
||||
`/org/${orgId}/site-provisioning-key`,
|
||||
{
|
||||
name: data.name,
|
||||
maxBatchSize: data.unlimitedBatchSize
|
||||
? null
|
||||
: data.maxBatchSize,
|
||||
validUntil:
|
||||
data.validUntil == null ||
|
||||
data.validUntil.trim() === ""
|
||||
? undefined
|
||||
: data.validUntil,
|
||||
approveNewSites: data.approveNewSites
|
||||
}
|
||||
)
|
||||
.put<
|
||||
AxiosResponse<CreateSiteProvisioningKeyResponse>
|
||||
>(`/org/${orgId}/site-provisioning-key`, {
|
||||
name: data.name,
|
||||
maxBatchSize: data.unlimitedBatchSize
|
||||
? null
|
||||
: data.maxBatchSize,
|
||||
validUntil:
|
||||
data.validUntil == null || data.validUntil.trim() === ""
|
||||
? undefined
|
||||
: data.validUntil
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -158,7 +152,9 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
}
|
||||
}
|
||||
|
||||
const credential = created && created.siteProvisioningKey;
|
||||
const credential =
|
||||
created &&
|
||||
created.siteProvisioningKey;
|
||||
|
||||
const unlimitedBatchSize = form.watch("unlimitedBatchSize");
|
||||
|
||||
@@ -217,12 +213,15 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
autoComplete="off"
|
||||
disabled={unlimitedBatchSize}
|
||||
disabled={
|
||||
unlimitedBatchSize
|
||||
}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const v =
|
||||
e.target.value;
|
||||
field.onChange(
|
||||
v === ""
|
||||
? 100
|
||||
@@ -270,7 +269,9 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
const dateTimeValue: DateTimeValue =
|
||||
(() => {
|
||||
if (!field.value) return {};
|
||||
const d = new Date(field.value);
|
||||
const d = new Date(
|
||||
field.value
|
||||
);
|
||||
if (isNaN(d.getTime()))
|
||||
return {};
|
||||
const hours = d
|
||||
@@ -312,7 +313,11 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
value.date
|
||||
);
|
||||
if (value.time) {
|
||||
const [h, m, s] =
|
||||
const [
|
||||
h,
|
||||
m,
|
||||
s
|
||||
] =
|
||||
value.time.split(
|
||||
":"
|
||||
);
|
||||
@@ -347,40 +352,6 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="approveNewSites"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="provisioning-approve-new-sites"
|
||||
checked={field.value}
|
||||
onCheckedChange={(c) =>
|
||||
field.onChange(
|
||||
c === true
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel
|
||||
htmlFor="provisioning-approve-new-sites"
|
||||
className="cursor-pointer font-normal !mt-0"
|
||||
>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSites"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSitesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
@@ -424,4 +395,4 @@ export default function CreateSiteProvisioningKeyCredenza({
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export type EditableSiteProvisioningKey = {
|
||||
name: string;
|
||||
maxBatchSize: number | null;
|
||||
validUntil: string | null;
|
||||
approveNewSites: boolean;
|
||||
};
|
||||
|
||||
type EditSiteProvisioningKeyCredenzaProps = {
|
||||
@@ -77,8 +76,7 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
.max(1_000_000, {
|
||||
message: t("provisioningKeysMaxBatchSizeInvalid")
|
||||
}),
|
||||
validUntil: z.string().optional(),
|
||||
approveNewSites: z.boolean()
|
||||
validUntil: z.string().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const v = data.validUntil;
|
||||
@@ -102,8 +100,7 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
name: "",
|
||||
unlimitedBatchSize: false,
|
||||
maxBatchSize: 100,
|
||||
validUntil: "",
|
||||
approveNewSites: true
|
||||
validUntil: ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,8 +112,7 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
name: provisioningKey.name,
|
||||
unlimitedBatchSize: provisioningKey.maxBatchSize == null,
|
||||
maxBatchSize: provisioningKey.maxBatchSize ?? 100,
|
||||
validUntil: provisioningKey.validUntil ?? "",
|
||||
approveNewSites: provisioningKey.approveNewSites
|
||||
validUntil: provisioningKey.validUntil ?? ""
|
||||
});
|
||||
}, [open, provisioningKey, form]);
|
||||
|
||||
@@ -139,8 +135,7 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
data.validUntil == null ||
|
||||
data.validUntil.trim() === ""
|
||||
? ""
|
||||
: data.validUntil,
|
||||
approveNewSites: data.approveNewSites
|
||||
: data.validUntil
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -260,38 +255,6 @@ export default function EditSiteProvisioningKeyCredenza({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="approveNewSites"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="provisioning-edit-approve-new-sites"
|
||||
checked={field.value}
|
||||
onCheckedChange={(c) =>
|
||||
field.onChange(c === true)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel
|
||||
htmlFor="provisioning-edit-approve-new-sites"
|
||||
className="cursor-pointer font-normal !mt-0"
|
||||
>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSites"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"provisioningKeysApproveNewSitesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validUntil"
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "./ui/controlled-data-table";
|
||||
import { SiteRow } from "./SitesTable";
|
||||
|
||||
type PendingSitesTableProps = {
|
||||
sites: SiteRow[];
|
||||
pagination: PaginationState;
|
||||
orgId: string;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function PendingSitesTable({
|
||||
sites,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: PendingSitesTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete(column);
|
||||
sp.delete("page");
|
||||
|
||||
if (value) {
|
||||
sp.set(column, value);
|
||||
}
|
||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function approveSite(siteId: number) {
|
||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.post(`/site/${siteId}`, { status: "accepted" });
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteApproveSuccess"),
|
||||
variant: "default"
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteApproveError"),
|
||||
description: formatAxiosError(e, t("siteApproveError"))
|
||||
});
|
||||
} finally {
|
||||
setApprovingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(siteId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ExtendedColumnDef<SiteRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: () => {
|
||||
return <span className="p-3">{t("identifier")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.nice || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("online") },
|
||||
{ value: "false", label: t("offline") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("online")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (
|
||||
originalRow.type == "newt" ||
|
||||
originalRow.type == "wireguard"
|
||||
) {
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return <span>-</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "mbIn",
|
||||
// friendlyName: t("dataIn"),
|
||||
// header: () => {
|
||||
// const dataInOrder = getSortDirection(
|
||||
// "megabytesIn",
|
||||
// searchParams
|
||||
// );
|
||||
// const Icon =
|
||||
// dataInOrder === "asc"
|
||||
// ? ArrowDown01Icon
|
||||
// : dataInOrder === "desc"
|
||||
// ? ArrowUp10Icon
|
||||
// : ChevronsUpDownIcon;
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() => toggleSort("megabytesIn")}
|
||||
// >
|
||||
// {t("dataIn")}
|
||||
// <Icon className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "mbOut",
|
||||
// friendlyName: t("dataOut"),
|
||||
// header: () => {
|
||||
// const dataOutOrder = getSortDirection(
|
||||
// "megabytesOut",
|
||||
// searchParams
|
||||
// );
|
||||
// const Icon =
|
||||
// dataOutOrder === "asc"
|
||||
// ? ArrowDown01Icon
|
||||
// : dataOutOrder === "desc"
|
||||
// ? ArrowUp10Icon
|
||||
// : ChevronsUpDownIcon;
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() => toggleSort("megabytesOut")}
|
||||
// >
|
||||
// {t("dataOut")}
|
||||
// <Icon className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("type"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Newt</span>
|
||||
{originalRow.newtVersion && (
|
||||
<span>v{originalRow.newtVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">WireGuard</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">Local</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("exitNode")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (!originalRow.exitNodeName) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const isCloudNode =
|
||||
build == "saas" &&
|
||||
originalRow.exitNodeName &&
|
||||
[
|
||||
"mercury",
|
||||
"venus",
|
||||
"earth",
|
||||
"mars",
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
const capitalizedName =
|
||||
originalRow.exitNodeName.charAt(0).toUpperCase() +
|
||||
originalRow.exitNodeName.slice(1).toLowerCase();
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
Pangolin {capitalizedName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.remoteExitNodeId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{originalRow.exitNodeName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{originalRow.exitNodeName}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: () => {
|
||||
return <span className="p-3">{t("address")}</span>;
|
||||
},
|
||||
cell: ({ row }: { row: any }) => {
|
||||
const originalRow = row.original;
|
||||
return originalRow.address ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{originalRow.address}</span>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
const isApproving = approvingIds.has(siteRow.id);
|
||||
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">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isApproving}
|
||||
onClick={() => approveSite(siteRow.id)}
|
||||
>
|
||||
<Check className="mr-2 w-4 h-4" />
|
||||
{t("approve")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={sites}
|
||||
tableId="pending-sites-table"
|
||||
searchPlaceholder={t("searchSitesProgress")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
nice: false,
|
||||
exitNode: false,
|
||||
address: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
981
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
981
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { TagInput } from "@app/components/tags/tag-input";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import {
|
||||
type AlertRuleFormAction,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export function DropdownAddAction({
|
||||
onPick
|
||||
}: {
|
||||
onPick: (type: "notify" | "sms" | "webhook") => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddAction")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-2" align="end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("notify");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionNotify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("sms");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionSms")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onPick("webhook");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionWebhook")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced] = useDebounce(q, 150);
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({ orgId, query: debounced, perPage: 500 })
|
||||
);
|
||||
const toggle = (id: number) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectSites")
|
||||
: t("alertingSitesSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
value={q}
|
||||
onValueChange={setQ}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((s) => (
|
||||
<CommandItem
|
||||
key={s.siteId}
|
||||
value={`${s.siteId}`}
|
||||
onSelect={() => toggle(s.siteId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(s.siteId)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{s.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ALERT_RESOURCES_PAGE_SIZE = 10;
|
||||
|
||||
function ResourceTenMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.resources({
|
||||
orgId,
|
||||
perPage: ALERT_RESOURCES_PAGE_SIZE
|
||||
})
|
||||
);
|
||||
const rows = useMemo(() => {
|
||||
const out: {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
targetIds: number[];
|
||||
}[] = [];
|
||||
for (const r of resources) {
|
||||
const targetIds = r.targets.map((x) => x.targetId);
|
||||
if (targetIds.length > 0) {
|
||||
out.push({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
targetIds
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [resources]);
|
||||
|
||||
const selectedResourceCount = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.targetIds.length > 0 &&
|
||||
row.targetIds.every((id) => value.includes(id))
|
||||
).length,
|
||||
[rows, value]
|
||||
);
|
||||
|
||||
const toggle = (targetIds: number[]) => {
|
||||
const allOn =
|
||||
targetIds.length > 0 &&
|
||||
targetIds.every((id) => value.includes(id));
|
||||
if (allOn) {
|
||||
onChange(value.filter((id) => !targetIds.includes(id)));
|
||||
} else {
|
||||
onChange([...new Set([...value, ...targetIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const summary =
|
||||
selectedResourceCount === 0
|
||||
? t("alertingSelectResources")
|
||||
: t("alertingResourcesSelected", {
|
||||
count: selectedResourceCount
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto p-2 space-y-0.5">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-2 py-1.5">
|
||||
{t("alertingResourcesEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const checked =
|
||||
row.targetIds.length > 0 &&
|
||||
row.targetIds.every((id) =>
|
||||
value.includes(id)
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={row.resourceId}
|
||||
type="button"
|
||||
onClick={() => toggle(row.targetIds)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
className="pointer-events-none shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionBlock({
|
||||
orgId,
|
||||
index,
|
||||
control,
|
||||
form,
|
||||
onRemove,
|
||||
canRemove
|
||||
}: {
|
||||
orgId: string;
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const type = form.watch(`actions.${index}.type`);
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-3 relative">
|
||||
{canRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.type`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingActionType")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
const nt = v as AlertRuleFormAction["type"];
|
||||
if (nt === "notify") {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "notify",
|
||||
userIds: [],
|
||||
roleIds: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else if (nt === "sms") {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "sms",
|
||||
phoneTags: []
|
||||
});
|
||||
} else {
|
||||
form.setValue(`actions.${index}`, {
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [{ key: "", value: "" }],
|
||||
secret: ""
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="notify">
|
||||
{t("alertingActionNotify")}
|
||||
</SelectItem>
|
||||
<SelectItem value="sms">
|
||||
{t("alertingActionSms")}
|
||||
</SelectItem>
|
||||
<SelectItem value="webhook">
|
||||
{t("alertingActionWebhook")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "notify" && (
|
||||
<NotifyActionFields
|
||||
orgId={orgId}
|
||||
index={index}
|
||||
control={control}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{type === "sms" && (
|
||||
<SmsActionFields index={index} control={control} form={form} />
|
||||
)}
|
||||
{type === "webhook" && (
|
||||
<WebhookActionFields
|
||||
index={index}
|
||||
control={control}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyActionFields({
|
||||
orgId,
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
orgId: string;
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
||||
const userIds = form.watch(`actions.${index}.userIds`) ?? [];
|
||||
const roleIds = form.watch(`actions.${index}.roleIds`) ?? [];
|
||||
const emailTags = form.watch(`actions.${index}.emailTags`) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-1">
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||
<UserMultiSelect
|
||||
orgId={orgId}
|
||||
value={userIds}
|
||||
onChange={(ids) =>
|
||||
form.setValue(`actions.${index}.userIds`, ids)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||
<RoleMultiSelect
|
||||
orgId={orgId}
|
||||
value={roleIds}
|
||||
onChange={(ids) =>
|
||||
form.setValue(`actions.${index}.roleIds`, ids)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.emailTags`}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingNotifyEmails")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
tags={emailTags}
|
||||
setTags={(updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater(emailTags)
|
||||
: updater;
|
||||
form.setValue(
|
||||
`actions.${index}.emailTags`,
|
||||
next
|
||||
);
|
||||
}}
|
||||
activeTagIndex={emailActiveIdx}
|
||||
setActiveTagIndex={setEmailActiveIdx}
|
||||
placeholder={t("alertingEmailPlaceholder")}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsActionFields({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [phoneActiveIdx, setPhoneActiveIdx] = useState<number | null>(null);
|
||||
const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? [];
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.phoneTags`}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingSmsNumbers")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
tags={phoneTags}
|
||||
setTags={(updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater(phoneTags)
|
||||
: updater;
|
||||
form.setValue(
|
||||
`actions.${index}.phoneTags`,
|
||||
next
|
||||
);
|
||||
}}
|
||||
activeTagIndex={phoneActiveIdx}
|
||||
setActiveTagIndex={setPhoneActiveIdx}
|
||||
placeholder={t("alertingSmsPlaceholder")}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookActionFields({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<div className="space-y-3 pt-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.url`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://example.com/hook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.method`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingWebhookMethod")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["GET", "POST", "PUT", "PATCH"].map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.secret`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingWebhookSecret")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
"alertingWebhookSecretPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<WebhookHeadersField index={index} control={control} form={form} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookHeadersField({
|
||||
index,
|
||||
control,
|
||||
form
|
||||
}: {
|
||||
index: number;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
form: UseFormReturn<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const headers =
|
||||
form.watch(`actions.${index}.headers` as const) ?? [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("alertingWebhookHeaders")}</FormLabel>
|
||||
{headers.map((_, hi) => (
|
||||
<div key={hi} className="flex gap-2 items-start">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.headers.${hi}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Key" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.headers.${hi}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Value" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
const cur =
|
||||
form.getValues(
|
||||
`actions.${index}.headers`
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`actions.${index}.headers`,
|
||||
cur.filter((__, i) => i !== hi)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const cur =
|
||||
form.getValues(`actions.${index}.headers`) ?? [];
|
||||
form.setValue(`actions.${index}.headers`, [
|
||||
...cur,
|
||||
{ key: "", value: "" }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddHeader")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced] = useDebounce(q, 150);
|
||||
const { data: users = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const shown = useMemo(() => {
|
||||
const qq = debounced.trim().toLowerCase();
|
||||
if (!qq) return users.slice(0, 200);
|
||||
return users
|
||||
.filter((u) => {
|
||||
const label = getUserDisplayName({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
username: u.username
|
||||
}).toLowerCase();
|
||||
return (
|
||||
label.includes(qq) ||
|
||||
(u.email ?? "").toLowerCase().includes(qq)
|
||||
);
|
||||
})
|
||||
.slice(0, 200);
|
||||
}, [users, debounced]);
|
||||
const toggle = (id: string) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectUsers")
|
||||
: t("alertingUsersSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onValueChange={setQ}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shown.map((u) => {
|
||||
const uid = String(u.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={uid}
|
||||
value={uid}
|
||||
onSelect={() => toggle(uid)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(uid)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{getUserDisplayName({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
username: u.username
|
||||
})}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMultiSelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
orgId: string;
|
||||
value: number[];
|
||||
onChange: (v: number[]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: roles = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
const toggle = (id: number) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
const summary =
|
||||
value.length === 0
|
||||
? t("alertingSelectRoles")
|
||||
: t("alertingRolesSelected", { count: value.length });
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{roles.map((r) => (
|
||||
<CommandItem
|
||||
key={r.roleId}
|
||||
value={`role-${r.roleId}`}
|
||||
onSelect={() => toggle(r.roleId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(r.roleId)}
|
||||
className="mr-2 pointer-events-none"
|
||||
/>
|
||||
{r.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertRuleSourceFields({
|
||||
orgId,
|
||||
control
|
||||
}: {
|
||||
orgId: string;
|
||||
control: Control<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { setValue, getValues } = useFormContext<AlertRuleFormValues>();
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sourceType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingSourceType")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
const next = v as AlertRuleFormValues["sourceType"];
|
||||
field.onChange(next);
|
||||
const curTrigger = getValues("trigger");
|
||||
if (next === "site") {
|
||||
if (
|
||||
curTrigger !== "site_online" &&
|
||||
curTrigger !== "site_offline"
|
||||
) {
|
||||
setValue("trigger", "site_offline", {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
curTrigger !== "health_check_healthy" &&
|
||||
curTrigger !== "health_check_unhealthy"
|
||||
) {
|
||||
setValue(
|
||||
"trigger",
|
||||
"health_check_unhealthy",
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
{t("alertingSourceSite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="health_check">
|
||||
{t("alertingSourceHealthCheck")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{sourceType === "site" ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="siteIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingPickSites")}</FormLabel>
|
||||
<SiteMultiSelect
|
||||
orgId={orgId}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name="targetIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingPickResources")}</FormLabel>
|
||||
<ResourceTenMultiSelect
|
||||
orgId={orgId}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertRuleTriggerFields({
|
||||
control
|
||||
}: {
|
||||
control: Control<AlertRuleFormValues>;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="trigger"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingTrigger")}</FormLabel>
|
||||
<Select
|
||||
key={sourceType}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{sourceType === "site" ? (
|
||||
<>
|
||||
<SelectItem value="site_online">
|
||||
{t("alertingTriggerSiteOnline")}
|
||||
</SelectItem>
|
||||
<SelectItem value="site_offline">
|
||||
{t("alertingTriggerSiteOffline")}
|
||||
</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="health_check_healthy">
|
||||
{t("alertingTriggerHcHealthy")}
|
||||
</SelectItem>
|
||||
<SelectItem value="health_check_unhealthy">
|
||||
{t("alertingTriggerHcUnhealthy")}
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
715
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
715
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
@@ -0,0 +1,715 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionBlock,
|
||||
AlertRuleSourceFields,
|
||||
AlertRuleTriggerFields,
|
||||
DropdownAddAction
|
||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
buildFormSchema,
|
||||
defaultFormValues,
|
||||
formValuesToRule,
|
||||
type AlertRuleFormAction,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { upsertRule } from "@app/lib/alertRulesLocalStorage";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Background,
|
||||
Handle,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type NodeTypes
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AlertRuleT = ReturnType<typeof useTranslations>;
|
||||
|
||||
export type AlertStepId = "source" | "trigger" | "actions";
|
||||
|
||||
type AlertStepNodeData = {
|
||||
roleLabel: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
configured: boolean;
|
||||
accent: string;
|
||||
topBorderClass: string;
|
||||
};
|
||||
|
||||
function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
|
||||
if (v.sourceType === "site") {
|
||||
if (v.siteIds.length === 0) {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
return t("alertingSummarySites", { count: v.siteIds.length });
|
||||
}
|
||||
if (v.targetIds.length === 0) {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
return t("alertingSummaryHealthChecks", { count: v.targetIds.length });
|
||||
}
|
||||
|
||||
function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
|
||||
switch (v.trigger) {
|
||||
case "site_online":
|
||||
return t("alertingTriggerSiteOnline");
|
||||
case "site_offline":
|
||||
return t("alertingTriggerSiteOffline");
|
||||
case "health_check_healthy":
|
||||
return t("alertingTriggerHcHealthy");
|
||||
case "health_check_unhealthy":
|
||||
return t("alertingTriggerHcUnhealthy");
|
||||
default:
|
||||
return v.trigger;
|
||||
}
|
||||
}
|
||||
|
||||
function oneActionConfigured(a: AlertRuleFormAction): boolean {
|
||||
if (a.type === "notify") {
|
||||
return (
|
||||
a.userIds.length > 0 ||
|
||||
a.roleIds.length > 0 ||
|
||||
a.emailTags.length > 0
|
||||
);
|
||||
}
|
||||
if (a.type === "sms") {
|
||||
return a.phoneTags.length > 0;
|
||||
}
|
||||
try {
|
||||
new URL(a.url.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
|
||||
switch (a.type) {
|
||||
case "notify":
|
||||
return t("alertingActionNotify");
|
||||
case "sms":
|
||||
return t("alertingActionSms");
|
||||
case "webhook":
|
||||
return t("alertingActionWebhook");
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
|
||||
if (a.type === "notify") {
|
||||
if (
|
||||
a.userIds.length === 0 &&
|
||||
a.roleIds.length === 0 &&
|
||||
a.emailTags.length === 0
|
||||
) {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (a.userIds.length > 0) {
|
||||
parts.push(t("alertingUsersSelected", { count: a.userIds.length }));
|
||||
}
|
||||
if (a.roleIds.length > 0) {
|
||||
parts.push(t("alertingRolesSelected", { count: a.roleIds.length }));
|
||||
}
|
||||
if (a.emailTags.length > 0) {
|
||||
parts.push(
|
||||
`${t("alertingNotifyEmails")} (${a.emailTags.length})`
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
if (a.type === "sms") {
|
||||
if (a.phoneTags.length === 0) {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
return `${t("alertingSmsNumbers")}: ${a.phoneTags.length}`;
|
||||
}
|
||||
const url = a.url.trim();
|
||||
if (!url) {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return t("alertingNodeNotConfigured");
|
||||
}
|
||||
}
|
||||
|
||||
function stepConfigured(
|
||||
step: "source" | "trigger",
|
||||
v: AlertRuleFormValues
|
||||
): boolean {
|
||||
if (step === "source") {
|
||||
return v.sourceType === "site"
|
||||
? v.siteIds.length > 0
|
||||
: v.targetIds.length > 0;
|
||||
}
|
||||
return Boolean(v.trigger);
|
||||
}
|
||||
|
||||
function buildActionStepNodeData(
|
||||
index: number,
|
||||
action: AlertRuleFormAction,
|
||||
t: AlertRuleT
|
||||
): AlertStepNodeData {
|
||||
return {
|
||||
roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`,
|
||||
title: actionTypeLabel(action, t),
|
||||
subtitle: summarizeOneAction(action, t),
|
||||
configured: oneActionConfigured(action),
|
||||
accent: "text-amber-600 dark:text-amber-400",
|
||||
topBorderClass: "border-t-amber-500"
|
||||
};
|
||||
}
|
||||
|
||||
function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData {
|
||||
return {
|
||||
roleLabel: t("alertingNodeRoleAction"),
|
||||
title: t("alertingSectionActions"),
|
||||
subtitle: t("alertingNodeNotConfigured"),
|
||||
configured: false,
|
||||
accent: "text-amber-600 dark:text-amber-400",
|
||||
topBorderClass: "border-t-amber-500"
|
||||
};
|
||||
}
|
||||
|
||||
const AlertStepNode = memo(function AlertStepNodeFn({
|
||||
data,
|
||||
selected
|
||||
}: NodeProps<Node<AlertStepNodeData>>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-xl border-2 border-t-[3px] bg-card px-5 py-4 shadow-sm min-w-[260px] max-w-[320px] transition-shadow",
|
||||
data.topBorderClass,
|
||||
selected
|
||||
? "border-primary ring-2 ring-primary/25 shadow-md"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-muted-foreground !w-2 !h-2"
|
||||
/>
|
||||
{data.configured && (
|
||||
<Check
|
||||
className="absolute top-3 right-3 h-5 w-5 text-green-600"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wide",
|
||||
data.accent
|
||||
)}
|
||||
>
|
||||
{data.roleLabel}
|
||||
</p>
|
||||
<p className="font-semibold text-base mt-1">{data.title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-3 leading-snug">
|
||||
{data.subtitle}
|
||||
</p>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-muted-foreground !w-2 !h-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
alertStep: AlertStepNode
|
||||
};
|
||||
|
||||
const ACTION_NODE_X_GAP = 280;
|
||||
const ACTION_NODE_Y = 468;
|
||||
const SOURCE_NODE_POS = { x: 120, y: 28 };
|
||||
const TRIGGER_NODE_POS = { x: 120, y: 248 };
|
||||
|
||||
function buildNodeData(
|
||||
stepId: "source" | "trigger",
|
||||
v: AlertRuleFormValues,
|
||||
t: AlertRuleT
|
||||
): AlertStepNodeData {
|
||||
const accents: Record<
|
||||
"source" | "trigger",
|
||||
{ accent: string; topBorderClass: string; role: string; title: string }
|
||||
> = {
|
||||
source: {
|
||||
accent: "text-blue-600 dark:text-blue-400",
|
||||
topBorderClass: "border-t-blue-500",
|
||||
role: t("alertingNodeRoleSource"),
|
||||
title: t("alertingSectionSource")
|
||||
},
|
||||
trigger: {
|
||||
accent: "text-emerald-600 dark:text-emerald-400",
|
||||
topBorderClass: "border-t-emerald-500",
|
||||
role: t("alertingNodeRoleTrigger"),
|
||||
title: t("alertingSectionTrigger")
|
||||
}
|
||||
};
|
||||
const meta = accents[stepId];
|
||||
const subtitle =
|
||||
stepId === "source"
|
||||
? summarizeSource(v, t)
|
||||
: summarizeTrigger(v, t);
|
||||
return {
|
||||
roleLabel: meta.role,
|
||||
title: meta.title,
|
||||
subtitle,
|
||||
configured: stepConfigured(stepId, v),
|
||||
accent: meta.accent,
|
||||
topBorderClass: meta.topBorderClass
|
||||
};
|
||||
}
|
||||
|
||||
type AlertRuleGraphEditorProps = {
|
||||
orgId: string;
|
||||
ruleId: string;
|
||||
createdAt: string;
|
||||
initialValues: AlertRuleFormValues;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
const FORM_ID = "alert-rule-graph-form";
|
||||
|
||||
export default function AlertRuleGraphEditor({
|
||||
orgId,
|
||||
ruleId,
|
||||
createdAt,
|
||||
initialValues,
|
||||
isNew
|
||||
}: AlertRuleGraphEditorProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const schema = useMemo(() => buildFormSchema(t), [t]);
|
||||
const form = useForm<AlertRuleFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues ?? defaultFormValues()
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "actions"
|
||||
});
|
||||
|
||||
const wName = useWatch({ control: form.control, name: "name" }) ?? "";
|
||||
const wEnabled =
|
||||
useWatch({ control: form.control, name: "enabled" }) ?? true;
|
||||
const wSourceType =
|
||||
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
|
||||
const wSiteIds =
|
||||
useWatch({ control: form.control, name: "siteIds" }) ?? [];
|
||||
const wTargetIds =
|
||||
useWatch({ control: form.control, name: "targetIds" }) ?? [];
|
||||
const wTrigger =
|
||||
useWatch({ control: form.control, name: "trigger" }) ??
|
||||
"site_offline";
|
||||
const wActions =
|
||||
useWatch({ control: form.control, name: "actions" }) ?? [];
|
||||
|
||||
const flowValues: AlertRuleFormValues = useMemo(
|
||||
() => ({
|
||||
name: wName,
|
||||
enabled: wEnabled,
|
||||
sourceType: wSourceType,
|
||||
siteIds: wSiteIds,
|
||||
targetIds: wTargetIds,
|
||||
trigger: wTrigger,
|
||||
actions: wActions
|
||||
}),
|
||||
[
|
||||
wName,
|
||||
wEnabled,
|
||||
wSourceType,
|
||||
wSiteIds,
|
||||
wTargetIds,
|
||||
wTrigger,
|
||||
wActions
|
||||
]
|
||||
);
|
||||
|
||||
const [selectedStep, setSelectedStep] = useState<string>("source");
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
const nodesSyncKeyRef = useRef("");
|
||||
useEffect(() => {
|
||||
const key = JSON.stringify({ flowValues, selectedStep });
|
||||
if (key === nodesSyncKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
nodesSyncKeyRef.current = key;
|
||||
|
||||
const nActions = flowValues.actions.length;
|
||||
const actionNodes: Node[] =
|
||||
nActions === 0
|
||||
? [
|
||||
{
|
||||
id: "actions",
|
||||
type: "alertStep",
|
||||
position: {
|
||||
x: TRIGGER_NODE_POS.x,
|
||||
y: ACTION_NODE_Y
|
||||
},
|
||||
data: buildActionsPlaceholderNodeData(t),
|
||||
selected:
|
||||
selectedStep === "actions" ||
|
||||
selectedStep.startsWith("action-")
|
||||
}
|
||||
]
|
||||
: flowValues.actions.map((action, i) => {
|
||||
const totalWidth =
|
||||
(nActions - 1) * ACTION_NODE_X_GAP;
|
||||
const originX =
|
||||
TRIGGER_NODE_POS.x - totalWidth / 2;
|
||||
return {
|
||||
id: `action-${i}`,
|
||||
type: "alertStep",
|
||||
position: {
|
||||
x: originX + i * ACTION_NODE_X_GAP,
|
||||
y: ACTION_NODE_Y
|
||||
},
|
||||
data: buildActionStepNodeData(i, action, t),
|
||||
selected: selectedStep === `action-${i}`
|
||||
};
|
||||
});
|
||||
|
||||
setNodes([
|
||||
{
|
||||
id: "source",
|
||||
type: "alertStep",
|
||||
position: SOURCE_NODE_POS,
|
||||
data: buildNodeData("source", flowValues, t),
|
||||
selected: selectedStep === "source"
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
type: "alertStep",
|
||||
position: TRIGGER_NODE_POS,
|
||||
data: buildNodeData("trigger", flowValues, t),
|
||||
selected: selectedStep === "trigger"
|
||||
},
|
||||
...actionNodes
|
||||
]);
|
||||
|
||||
const nextEdges: Edge[] = [
|
||||
{
|
||||
id: "e-src-trg",
|
||||
source: "source",
|
||||
target: "trigger",
|
||||
animated: true
|
||||
},
|
||||
...(nActions === 0
|
||||
? [
|
||||
{
|
||||
id: "e-trg-act",
|
||||
source: "trigger",
|
||||
target: "actions",
|
||||
animated: true
|
||||
} as const
|
||||
]
|
||||
: flowValues.actions.map((_, i) => ({
|
||||
id: `e-trg-act-${i}`,
|
||||
source: "trigger",
|
||||
target: `action-${i}`,
|
||||
animated: true
|
||||
})))
|
||||
];
|
||||
setEdges(nextEdges);
|
||||
}, [flowValues, selectedStep, t, setNodes, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStep === "actions" && wActions.length > 0) {
|
||||
setSelectedStep("action-0");
|
||||
}
|
||||
}, [selectedStep, wActions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) {
|
||||
setSelectedStep("actions");
|
||||
}
|
||||
}, [wActions.length, selectedStep]);
|
||||
|
||||
useEffect(() => {
|
||||
const m = /^action-(\d+)$/.exec(selectedStep);
|
||||
if (!m) {
|
||||
return;
|
||||
}
|
||||
const i = Number(m[1], 10);
|
||||
if (i >= wActions.length) {
|
||||
setSelectedStep(
|
||||
wActions.length > 0
|
||||
? `action-${wActions.length - 1}`
|
||||
: "actions"
|
||||
);
|
||||
}
|
||||
}, [wActions.length, selectedStep]);
|
||||
|
||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
||||
setSelectedStep(node.id);
|
||||
}, []);
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
const next = formValuesToRule(values, ruleId, createdAt);
|
||||
upsertRule(orgId, next);
|
||||
toast({ title: t("alertingRuleSaved") });
|
||||
if (isNew) {
|
||||
router.replace(`/${orgId}/settings/alerting/${ruleId}`);
|
||||
}
|
||||
});
|
||||
|
||||
const isActionsSidebar =
|
||||
selectedStep === "actions" || selectedStep.startsWith("action-");
|
||||
|
||||
const sidebarTitle = isActionsSidebar
|
||||
? t("alertingConfigureActions")
|
||||
: selectedStep === "source"
|
||||
? t("alertingConfigureSource")
|
||||
: t("alertingConfigureTrigger");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||
<SettingsContainer>
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-5 space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:flex-wrap md:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/${orgId}/settings/alerting`}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{t("alertingBackToRules")}
|
||||
</Link>
|
||||
</Button>
|
||||
{isNew && (
|
||||
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
|
||||
{t("alertingDraftBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-0 md:min-w-[12rem] md:max-w-md">
|
||||
<FormLabel className="sr-only">
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"alertingRuleNamePlaceholder"
|
||||
)}
|
||||
className="font-medium"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3 md:ml-auto">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormLabel className="text-sm font-normal cursor-pointer whitespace-nowrap">
|
||||
{t("alertingRuleEnabled")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<Card className="flex flex-col w-full overflow-hidden">
|
||||
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
||||
<CardTitle className="text-lg font-bold tracking-tight">
|
||||
{t("alertingGraphCanvasTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription className="pt-0">
|
||||
{t("alertingGraphCanvasDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border bg-muted/30 overflow-hidden",
|
||||
"min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
||||
)}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
padding: 0.35
|
||||
}}
|
||||
minZoom={0.5}
|
||||
maxZoom={1.25}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
panOnScroll
|
||||
zoomOnScroll
|
||||
proOptions={{
|
||||
hideAttribution: true
|
||||
}}
|
||||
className="bg-transparent !h-full !w-full min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
||||
>
|
||||
<Background gap={16} size={1} />
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col w-full">
|
||||
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
||||
<CardTitle className="text-lg font-bold tracking-tight">
|
||||
{sidebarTitle}
|
||||
</CardTitle>
|
||||
<CardDescription className="pt-0">
|
||||
{t("alertingSidebarHint")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
||||
<div className="space-y-6">
|
||||
{selectedStep === "source" && (
|
||||
<AlertRuleSourceFields
|
||||
orgId={orgId}
|
||||
control={form.control}
|
||||
/>
|
||||
)}
|
||||
{selectedStep === "trigger" && (
|
||||
<AlertRuleTriggerFields
|
||||
control={form.control}
|
||||
/>
|
||||
)}
|
||||
{isActionsSidebar && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"alertingSectionActions"
|
||||
)}
|
||||
</span>
|
||||
<DropdownAddAction
|
||||
onPick={(type) => {
|
||||
const newIndex =
|
||||
fields.length;
|
||||
if (type === "notify") {
|
||||
append({
|
||||
type: "notify",
|
||||
userIds: [],
|
||||
roleIds: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else if (
|
||||
type === "sms"
|
||||
) {
|
||||
append({
|
||||
type: "sms",
|
||||
phoneTags: []
|
||||
});
|
||||
} else {
|
||||
append({
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [
|
||||
{
|
||||
key: "",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
secret: ""
|
||||
});
|
||||
}
|
||||
setSelectedStep(
|
||||
`action-${newIndex}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{fields.map((f, index) => (
|
||||
<ActionBlock
|
||||
key={f.id}
|
||||
orgId={orgId}
|
||||
index={index}
|
||||
control={form.control}
|
||||
form={form}
|
||||
onRemove={() =>
|
||||
remove(index)
|
||||
}
|
||||
canRemove
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
283
src/lib/alertRuleForm.ts
Normal file
283
src/lib/alertRuleForm.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
type AlertRule,
|
||||
type AlertTrigger,
|
||||
isoNow,
|
||||
type AlertAction as StoredAlertAction
|
||||
} from "@app/lib/alertRulesLocalStorage";
|
||||
import { z } from "zod";
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
});
|
||||
|
||||
export type AlertRuleFormAction =
|
||||
| {
|
||||
type: "notify";
|
||||
userIds: string[];
|
||||
roleIds: number[];
|
||||
emailTags: Tag[];
|
||||
}
|
||||
| { type: "sms"; phoneTags: Tag[] }
|
||||
| {
|
||||
type: "webhook";
|
||||
url: string;
|
||||
method: string;
|
||||
headers: { key: string; value: string }[];
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type AlertRuleFormValues = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
sourceType: "site" | "health_check";
|
||||
siteIds: number[];
|
||||
targetIds: number[];
|
||||
trigger: AlertTrigger;
|
||||
actions: AlertRuleFormAction[];
|
||||
};
|
||||
|
||||
export function buildFormSchema(t: (k: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().min(1, { message: t("alertingErrorNameRequired") }),
|
||||
enabled: z.boolean(),
|
||||
sourceType: z.enum(["site", "health_check"]),
|
||||
siteIds: z.array(z.number()),
|
||||
targetIds: z.array(z.number()),
|
||||
trigger: z.enum([
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy"
|
||||
]),
|
||||
actions: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("notify"),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.number()),
|
||||
emailTags: z.array(tagSchema)
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sms"),
|
||||
phoneTags: z.array(tagSchema)
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("webhook"),
|
||||
url: z.string(),
|
||||
method: z.string(),
|
||||
headers: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
})
|
||||
),
|
||||
secret: z.string()
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.actions.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorActionsMin"),
|
||||
path: ["actions"]
|
||||
});
|
||||
}
|
||||
if (val.sourceType === "site" && val.siteIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorPickSites"),
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "health_check" &&
|
||||
val.targetIds.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorPickHealthChecks"),
|
||||
path: ["targetIds"]
|
||||
});
|
||||
}
|
||||
const siteTriggers: AlertTrigger[] = [
|
||||
"site_online",
|
||||
"site_offline"
|
||||
];
|
||||
const hcTriggers: AlertTrigger[] = [
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy"
|
||||
];
|
||||
if (
|
||||
val.sourceType === "site" &&
|
||||
!siteTriggers.includes(val.trigger)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorTriggerSite"),
|
||||
path: ["trigger"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "health_check" &&
|
||||
!hcTriggers.includes(val.trigger)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorTriggerHealth"),
|
||||
path: ["trigger"]
|
||||
});
|
||||
}
|
||||
val.actions.forEach((a, i) => {
|
||||
if (a.type === "notify") {
|
||||
if (
|
||||
a.userIds.length === 0 &&
|
||||
a.roleIds.length === 0 &&
|
||||
a.emailTags.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorNotifyRecipients"),
|
||||
path: ["actions", i, "userIds"]
|
||||
});
|
||||
}
|
||||
}
|
||||
if (a.type === "sms" && a.phoneTags.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorSmsPhones"),
|
||||
path: ["actions", i, "phoneTags"]
|
||||
});
|
||||
}
|
||||
if (a.type === "webhook") {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(a.url.trim());
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorWebhookUrl"),
|
||||
path: ["actions", i, "url"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function defaultFormValues(): AlertRuleFormValues {
|
||||
return {
|
||||
name: "",
|
||||
enabled: true,
|
||||
sourceType: "site",
|
||||
siteIds: [],
|
||||
targetIds: [],
|
||||
trigger: "site_offline",
|
||||
actions: [
|
||||
{
|
||||
type: "notify",
|
||||
userIds: [],
|
||||
roleIds: [],
|
||||
emailTags: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues {
|
||||
const actions: AlertRuleFormAction[] = rule.actions.map(
|
||||
(a: StoredAlertAction) => {
|
||||
if (a.type === "notify") {
|
||||
return {
|
||||
type: "notify",
|
||||
userIds: a.userIds.map(String),
|
||||
roleIds: [...a.roleIds],
|
||||
emailTags: a.emails.map((e) => ({ id: e, text: e }))
|
||||
};
|
||||
}
|
||||
if (a.type === "sms") {
|
||||
return {
|
||||
type: "sms",
|
||||
phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p }))
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "webhook",
|
||||
url: a.url,
|
||||
method: a.method,
|
||||
headers:
|
||||
a.headers.length > 0
|
||||
? a.headers.map((h) => ({ ...h }))
|
||||
: [{ key: "", value: "" }],
|
||||
secret: a.secret ?? ""
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
sourceType: rule.source.type,
|
||||
siteIds:
|
||||
rule.source.type === "site" ? [...rule.source.siteIds] : [],
|
||||
targetIds:
|
||||
rule.source.type === "health_check"
|
||||
? [...rule.source.targetIds]
|
||||
: [],
|
||||
trigger: rule.trigger,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
export function formValuesToRule(
|
||||
v: AlertRuleFormValues,
|
||||
id: string,
|
||||
createdAt: string
|
||||
): AlertRule {
|
||||
const source =
|
||||
v.sourceType === "site"
|
||||
? { type: "site" as const, siteIds: v.siteIds }
|
||||
: {
|
||||
type: "health_check" as const,
|
||||
targetIds: v.targetIds
|
||||
};
|
||||
const actions = v.actions.map((a) => {
|
||||
if (a.type === "notify") {
|
||||
return {
|
||||
type: "notify" as const,
|
||||
userIds: a.userIds,
|
||||
roleIds: a.roleIds,
|
||||
emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
if (a.type === "sms") {
|
||||
return {
|
||||
type: "sms" as const,
|
||||
phoneNumbers: a.phoneTags
|
||||
.map((tg) => tg.text.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "webhook" as const,
|
||||
url: a.url.trim(),
|
||||
method: a.method,
|
||||
headers: a.headers.filter((h) => h.key.trim() || h.value.trim()),
|
||||
secret: a.secret.trim() || undefined
|
||||
};
|
||||
});
|
||||
return {
|
||||
id,
|
||||
name: v.name.trim(),
|
||||
enabled: v.enabled,
|
||||
createdAt,
|
||||
updatedAt: isoNow(),
|
||||
source,
|
||||
trigger: v.trigger,
|
||||
actions
|
||||
};
|
||||
}
|
||||
133
src/lib/alertRulesLocalStorage.ts
Normal file
133
src/lib/alertRulesLocalStorage.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const STORAGE_PREFIX = "pangolin:alert-rules:";
|
||||
|
||||
export const webhookHeaderEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
});
|
||||
|
||||
export const alertActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("notify"),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.number()),
|
||||
emails: z.array(z.string())
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sms"),
|
||||
phoneNumbers: z.array(z.string())
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("webhook"),
|
||||
url: z.string().url(),
|
||||
method: z.string().min(1),
|
||||
headers: z.array(webhookHeaderEntrySchema),
|
||||
secret: z.string().optional()
|
||||
})
|
||||
]);
|
||||
|
||||
export const alertSourceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("site"),
|
||||
siteIds: z.array(z.number())
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("health_check"),
|
||||
targetIds: z.array(z.number())
|
||||
})
|
||||
]);
|
||||
|
||||
export const alertTriggerSchema = z.enum([
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy"
|
||||
]);
|
||||
|
||||
export const alertRuleSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
source: alertSourceSchema,
|
||||
trigger: alertTriggerSchema,
|
||||
actions: z.array(alertActionSchema).min(1)
|
||||
});
|
||||
|
||||
export type AlertRule = z.infer<typeof alertRuleSchema>;
|
||||
export type AlertAction = z.infer<typeof alertActionSchema>;
|
||||
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
|
||||
|
||||
function storageKey(orgId: string) {
|
||||
return `${STORAGE_PREFIX}${orgId}`;
|
||||
}
|
||||
|
||||
export function getRule(orgId: string, ruleId: string): AlertRule | undefined {
|
||||
return loadRules(orgId).find((r) => r.id === ruleId);
|
||||
}
|
||||
|
||||
export function loadRules(orgId: string): AlertRule[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(orgId));
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
const out: AlertRule[] = [];
|
||||
for (const item of parsed) {
|
||||
const r = alertRuleSchema.safeParse(item);
|
||||
if (r.success) {
|
||||
out.push(r.data);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRules(orgId: string, rules: AlertRule[]) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
|
||||
}
|
||||
|
||||
export function upsertRule(orgId: string, rule: AlertRule) {
|
||||
const rules = loadRules(orgId);
|
||||
const i = rules.findIndex((r) => r.id === rule.id);
|
||||
if (i >= 0) {
|
||||
rules[i] = rule;
|
||||
} else {
|
||||
rules.push(rule);
|
||||
}
|
||||
saveRules(orgId, rules);
|
||||
}
|
||||
|
||||
export function deleteRule(orgId: string, ruleId: string) {
|
||||
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
|
||||
saveRules(orgId, rules);
|
||||
}
|
||||
|
||||
export function newRuleId() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export function isoNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user