Compare commits

..

2 Commits

Author SHA1 Message Date
miloschwartz
4cce6e0820 add node graph and editor 2026-03-29 20:25:17 -07:00
miloschwartz
2841c5ed4e basic rules 2026-03-29 14:19:26 -07:00
33 changed files with 2844 additions and 894 deletions

View File

@@ -331,11 +331,6 @@
"provisioningKeysTitle": "Provisioning Key", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "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", "provisioningKeys": "Provisioning Keys",
"searchProvisioningKeys": "Search provisioning keys...", "searchProvisioningKeys": "Search provisioning keys...",
"provisioningKeysAdd": "Generate Provisioning Key", "provisioningKeysAdd": "Generate Provisioning Key",
@@ -365,17 +360,9 @@
"provisioningKeysNeverUsed": "Never", "provisioningKeysNeverUsed": "Never",
"provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEdit": "Edit Provisioning Key",
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this 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", "provisioningKeysUpdateError": "Error updating provisioning key",
"provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdated": "Provisioning key updated",
"provisioningKeysUpdatedDescription": "Your changes have been saved.", "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", "apiKeysSettings": "{apiKeyName} Settings",
"userTitle": "Manage All Users", "userTitle": "Manage All Users",
"userDescription": "View and manage all users in the system", "userDescription": "View and manage all users in the system",
@@ -1250,6 +1237,7 @@
"actionViewLogs": "View Logs", "actionViewLogs": "View Logs",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"search": "Search…",
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
"emptySearchOptions": "No options found", "emptySearchOptions": "No options found",
"create": "Create", "create": "Create",
@@ -1334,10 +1322,96 @@
"sidebarGeneral": "Manage", "sidebarGeneral": "Manage",
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarAlerting": "Alerting",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarManagement": "Management", "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses", "sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics", "sidebarLogsAnalytics": "Analytics",
"alertingTitle": "Alerting rules",
"alertingDescription": "Define sources, triggers, and actions for notifications.",
"alertingRules": "Alert rules",
"alertingSearchRules": "Search rules…",
"alertingAddRule": "Create Rule",
"alertingColumnSource": "Source",
"alertingColumnTrigger": "Trigger",
"alertingColumnActions": "Actions",
"alertingColumnEnabled": "Enabled",
"alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.",
"alertingDeleteRule": "Delete alert rule",
"alertingRuleDeleted": "Alert rule deleted",
"alertingRuleSaved": "Alert rule saved",
"alertingEditRule": "Edit alert rule",
"alertingCreateRule": "Create alert rule",
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
"alertingRuleNamePlaceholder": "Production site down",
"alertingRuleEnabled": "Rule enabled",
"alertingSectionSource": "Source",
"alertingSourceType": "Source type",
"alertingSourceSite": "Site",
"alertingSourceHealthCheck": "Health check",
"alertingPickSites": "Sites",
"alertingPickHealthChecks": "Health checks",
"alertingPickResources": "Resources",
"alertingSelectResources": "Select resources…",
"alertingResourcesSelected": "{count} resources selected",
"alertingResourcesEmpty": "No resources with targets in the first 10 results.",
"alertingSectionTrigger": "Trigger",
"alertingTrigger": "When to alert",
"alertingTriggerSiteOnline": "Site online",
"alertingTriggerSiteOffline": "Site offline",
"alertingTriggerHcHealthy": "Health check healthy",
"alertingTriggerHcUnhealthy": "Health check unhealthy",
"alertingSectionActions": "Actions",
"alertingAddAction": "Add action",
"alertingActionNotify": "Notify",
"alertingActionSms": "SMS",
"alertingActionWebhook": "Webhook",
"alertingActionType": "Action type",
"alertingNotifyUsers": "Users",
"alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email addresses",
"alertingEmailPlaceholder": "Add email and press Enter",
"alertingSmsNumbers": "Phone numbers",
"alertingSmsPlaceholder": "Add number and press Enter",
"alertingWebhookMethod": "HTTP method",
"alertingWebhookSecret": "Signing secret (optional)",
"alertingWebhookSecretPlaceholder": "HMAC secret",
"alertingWebhookHeaders": "Headers",
"alertingAddHeader": "Add header",
"alertingSelectSites": "Select sites…",
"alertingSitesSelected": "{count} sites selected",
"alertingSelectHealthChecks": "Select health checks…",
"alertingHealthChecksSelected": "{count} health checks selected",
"alertingNoHealthChecks": "No targets with health checks enabled",
"alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.",
"alertingSelectUsers": "Select users…",
"alertingUsersSelected": "{count} users selected",
"alertingSelectRoles": "Select roles…",
"alertingRolesSelected": "{count} roles selected",
"alertingSummarySites": "Sites ({count})",
"alertingSummaryHealthChecks": "Health checks ({count})",
"alertingErrorNameRequired": "Enter a name",
"alertingErrorActionsMin": "Add at least one action",
"alertingErrorPickSites": "Select at least one site",
"alertingErrorPickHealthChecks": "Select at least one health check",
"alertingErrorTriggerSite": "Choose a site trigger",
"alertingErrorTriggerHealth": "Choose a health check trigger",
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
"alertingErrorSmsPhones": "Add at least one phone number",
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
"alertingConfigureSource": "Configure Source",
"alertingConfigureTrigger": "Configure Trigger",
"alertingConfigureActions": "Configure Actions",
"alertingBackToRules": "Back to Rules",
"alertingDraftBadge": "Draft — save to store this rule",
"alertingSidebarHint": "Click a step on the canvas to edit it here.",
"alertingGraphCanvasTitle": "Rule Flow",
"alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.",
"alertingNodeNotConfigured": "Not configured yet",
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
"alertingNodeRoleSource": "Source",
"alertingNodeRoleTrigger": "Trigger",
"alertingNodeRoleAction": "Action",
"blueprints": "Blueprints", "blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintAdd": "Add Blueprint", "blueprintAdd": "Add Blueprint",

130
package-lock.json generated
View File

@@ -44,6 +44,7 @@
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xyflow/react": "^12.8.4",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.13.5", "axios": "1.13.5",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
@@ -1058,6 +1059,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -2353,6 +2355,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2375,6 +2378,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2397,6 +2401,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2413,6 +2418,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2429,6 +2435,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2445,6 +2452,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2461,6 +2469,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2477,6 +2486,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2493,6 +2503,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2509,6 +2520,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2525,6 +2537,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2541,6 +2554,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2563,6 +2577,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2585,6 +2600,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2607,6 +2623,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2629,6 +2646,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2651,6 +2669,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2673,6 +2692,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2695,6 +2715,7 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2714,6 +2735,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2733,6 +2755,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2752,6 +2775,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3002,6 +3026,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
}, },
@@ -6948,6 +6973,7 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -8408,6 +8434,7 @@
"version": "5.90.21", "version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.20" "@tanstack/query-core": "5.90.20"
}, },
@@ -8523,6 +8550,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -8682,7 +8710,6 @@
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/d3-selection": "*" "@types/d3-selection": "*"
@@ -8798,7 +8825,6 @@
"version": "3.0.11", "version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-shape": { "node_modules/@types/d3-shape": {
@@ -8833,7 +8859,6 @@
"version": "3.0.9", "version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/d3-selection": "*" "@types/d3-selection": "*"
@@ -8843,7 +8868,6 @@
"version": "3.0.8", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/d3-interpolate": "*", "@types/d3-interpolate": "*",
@@ -8870,6 +8894,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -8965,6 +8990,7 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -8992,6 +9018,7 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"pg-protocol": "*", "pg-protocol": "*",
@@ -9017,6 +9044,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -9027,6 +9055,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -9113,8 +9142,7 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true
"peer": true
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
@@ -9188,6 +9216,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -9642,6 +9671,38 @@
"win32" "win32"
] ]
}, },
"node_modules/@xyflow/react": {
"version": "12.8.4",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.68",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -9661,6 +9722,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -10109,6 +10171,7 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/types": "^7.26.0" "@babel/types": "^7.26.0"
} }
@@ -10180,6 +10243,7 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -10308,6 +10372,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -10490,6 +10555,12 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-spinners": { "node_modules/cli-spinners": {
"version": "2.9.2", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
@@ -11214,6 +11285,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -11654,7 +11726,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
@@ -12289,6 +12360,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -12374,6 +12446,7 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -12510,6 +12583,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -12903,6 +12977,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -15320,7 +15395,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"dompurify": "3.2.7", "dompurify": "3.2.7",
"marked": "14.0.0" "marked": "14.0.0"
@@ -15331,7 +15405,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@@ -15419,6 +15492,7 @@
"version": "15.5.12", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
"peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.5.12", "@next/env": "15.5.12",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
@@ -16377,6 +16451,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -16881,6 +16956,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -16912,6 +16988,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -17204,6 +17281,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -18665,7 +18743,8 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -19140,6 +19219,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -19567,6 +19647,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@colors/colors": "^1.6.0", "@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8", "@dabh/diagnostics": "^2.0.8",
@@ -19773,6 +19854,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -19788,6 +19870,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }

View File

@@ -67,6 +67,7 @@
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xyflow/react": "^12.8.4",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.13.5", "axios": "1.13.5",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",

View File

@@ -391,8 +391,7 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
lastUsed: varchar("lastUsed", { length: 255 }), lastUsed: varchar("lastUsed", { length: 255 }),
maxBatchSize: integer("maxBatchSize"), // null = no limit maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0), numUsed: integer("numUsed").notNull().default(0),
validUntil: varchar("validUntil", { length: 255 }), validUntil: varchar("validUntil", { length: 255 })
approveNewSites: boolean("approveNewSites").notNull().default(true)
}); });
export const siteProvisioningKeyOrg = pgTable( export const siteProvisioningKeyOrg = pgTable(

View File

@@ -100,8 +100,7 @@ export const sites = pgTable("sites", {
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
status: varchar("status").$type<"pending" | "approved">()
}); });
export const resources = pgTable("resources", { export const resources = pgTable("resources", {

View File

@@ -375,10 +375,7 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
lastUsed: text("lastUsed"), lastUsed: text("lastUsed"),
maxBatchSize: integer("maxBatchSize"), // null = no limit maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0), numUsed: integer("numUsed").notNull().default(0),
validUntil: text("validUntil"), validUntil: text("validUntil")
approveNewSites: integer("approveNewSites", { mode: "boolean" })
.notNull()
.default(true)
}); });
export const siteProvisioningKeyOrg = sqliteTable( export const siteProvisioningKeyOrg = sqliteTable(

View File

@@ -110,8 +110,7 @@ export const sites = sqliteTable("sites", {
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true)
status: text("status").$type<"pending" | "approved">()
}); });
export const resources = sqliteTable("resources", { export const resources = sqliteTable("resources", {

View File

@@ -38,8 +38,7 @@ const bodySchema = z
z.null(), z.null(),
z.coerce.number().int().positive().max(1_000_000) z.coerce.number().int().positive().max(1_000_000)
]), ]),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional()
approveNewSites: z.boolean().optional().default(true)
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -83,7 +82,7 @@ export async function createSiteProvisioningKey(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { name, maxBatchSize, approveNewSites } = parsedBody.data; const { name, maxBatchSize } = parsedBody.data;
const vuRaw = parsedBody.data.validUntil; const vuRaw = parsedBody.data.validUntil;
const validUntil = const validUntil =
vuRaw == null || vuRaw.trim() === "" vuRaw == null || vuRaw.trim() === ""
@@ -107,8 +106,7 @@ export async function createSiteProvisioningKey(
lastUsed: null, lastUsed: null,
maxBatchSize, maxBatchSize,
numUsed: 0, numUsed: 0,
validUntil, validUntil
approveNewSites
}); });
await trx.insert(siteProvisioningKeyOrg).values({ await trx.insert(siteProvisioningKeyOrg).values({
@@ -129,8 +127,7 @@ export async function createSiteProvisioningKey(
lastUsed: null, lastUsed: null,
maxBatchSize, maxBatchSize,
numUsed: 0, numUsed: 0,
validUntil, validUntil
approveNewSites
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -57,8 +57,7 @@ function querySiteProvisioningKeys(orgId: string) {
lastUsed: siteProvisioningKeys.lastUsed, lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil, validUntil: siteProvisioningKeys.validUntil
approveNewSites: siteProvisioningKeys.approveNewSites
}) })
.from(siteProvisioningKeyOrg) .from(siteProvisioningKeyOrg)
.innerJoin( .innerJoin(

View File

@@ -39,18 +39,16 @@ const bodySchema = z
z.coerce.number().int().positive().max(1_000_000) z.coerce.number().int().positive().max(1_000_000)
]) ])
.optional(), .optional(),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional()
approveNewSites: z.boolean().optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if ( if (
data.maxBatchSize === undefined && data.maxBatchSize === undefined &&
data.validUntil === undefined && data.validUntil === undefined
data.approveNewSites === undefined
) { ) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", message: "Provide maxBatchSize and/or validUntil",
path: ["maxBatchSize"] path: ["maxBatchSize"]
}); });
} }
@@ -131,7 +129,6 @@ export async function updateSiteProvisioningKey(
const setValues: { const setValues: {
maxBatchSize?: number | null; maxBatchSize?: number | null;
validUntil?: string | null; validUntil?: string | null;
approveNewSites?: boolean;
} = {}; } = {};
if (body.maxBatchSize !== undefined) { if (body.maxBatchSize !== undefined) {
setValues.maxBatchSize = body.maxBatchSize; setValues.maxBatchSize = body.maxBatchSize;
@@ -142,9 +139,6 @@ export async function updateSiteProvisioningKey(
? null ? null
: new Date(Date.parse(body.validUntil)).toISOString(); : new Date(Date.parse(body.validUntil)).toISOString();
} }
if (body.approveNewSites !== undefined) {
setValues.approveNewSites = body.approveNewSites;
}
await db await db
.update(siteProvisioningKeys) .update(siteProvisioningKeys)
@@ -166,8 +160,7 @@ export async function updateSiteProvisioningKey(
lastUsed: siteProvisioningKeys.lastUsed, lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil, validUntil: siteProvisioningKeys.validUntil
approveNewSites: siteProvisioningKeys.approveNewSites
}) })
.from(siteProvisioningKeys) .from(siteProvisioningKeys)
.where( .where(

View File

@@ -82,8 +82,7 @@ export async function registerNewt(
orgId: siteProvisioningKeyOrg.orgId, orgId: siteProvisioningKeyOrg.orgId,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil, validUntil: siteProvisioningKeys.validUntil
approveNewSites: siteProvisioningKeys.approveNewSites,
}) })
.from(siteProvisioningKeys) .from(siteProvisioningKeys)
.innerJoin( .innerJoin(
@@ -197,8 +196,7 @@ export async function registerNewt(
name: niceId, name: niceId,
niceId, niceId,
type: "newt", type: "newt",
dockerSocketEnabled: true, dockerSocketEnabled: true
status: keyRecord.approveNewSites ? "approved" : "pending",
}) })
.returning(); .returning();

View File

@@ -298,8 +298,7 @@ export async function createSite(
niceId, niceId,
address: updatedAddress || null, address: updatedAddress || null,
type, type,
dockerSocketEnabled: true, dockerSocketEnabled: true
status: "approved"
}) })
.returning(); .returning();
} else if (type == "wireguard") { } else if (type == "wireguard") {
@@ -356,8 +355,7 @@ export async function createSite(
niceId, niceId,
subnet, subnet,
type, type,
pubKey: pubKey || null, pubKey: pubKey || null
status: "approved"
}) })
.returning(); .returning();
} else if (type == "local") { } else if (type == "local") {
@@ -372,8 +370,7 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/32", subnet: "0.0.0.0/32"
status: "approved"
}) })
.returning(); .returning();
} else { } else {

View File

@@ -135,15 +135,6 @@ const listSitesSchema = z.object({
.openapi({ .openapi({
type: "boolean", type: "boolean",
description: "Filter by online status" 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, exitNodeId: sites.exitNodeId,
exitNodeName: exitNodes.name, exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint, exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId, remoteExitNodeId: remoteExitNodes.remoteExitNodeId
status: sites.status
}) })
.from(sites) .from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(orgs, eq(sites.orgId, orgs.orgId))
@@ -255,7 +245,7 @@ export async function listSites(
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
} }
const { pageSize, page, query, sort_by, order, online, status } = const { pageSize, page, query, sort_by, order, online } =
parsedQuery.data; parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -283,9 +273,6 @@ export async function listSites(
if (typeof online !== "undefined") { if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online)); conditions.push(eq(sites.online, online));
} }
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
const baseQuery = querySitesBase().where(and(...conditions)); const baseQuery = querySitesBase().where(and(...conditions));

View File

@@ -19,8 +19,7 @@ const updateSiteBodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(), dockerSocketEnabled: z.boolean().optional()
status: z.enum(["pending", "approved"]).optional(),
// remoteSubnets: z.string().optional() // remoteSubnets: z.string().optional()
// subdomain: z // subdomain: z
// .string() // .string()

View File

@@ -8,7 +8,6 @@ export type SiteProvisioningKeyListItem = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
export type ListSiteProvisioningKeysResponse = { export type ListSiteProvisioningKeysResponse = {
@@ -27,7 +26,6 @@ export type CreateSiteProvisioningKeyResponse = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
export type UpdateSiteProvisioningKeyResponse = { export type UpdateSiteProvisioningKeyResponse = {
@@ -40,5 +38,4 @@ export type UpdateSiteProvisioningKeyResponse = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };

View File

@@ -340,7 +340,8 @@ export default function Page() {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api.post<AxiosResponse<InviteUserResponse>>( const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`, `/org/${orgId}/create-invite`,
{ {
email: values.email, email: values.email,

View File

@@ -0,0 +1,52 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import { ruleToFormValues } from "@app/lib/alertRuleForm";
import type { AlertRule } from "@app/lib/alertRulesLocalStorage";
import { getRule } from "@app/lib/alertRulesLocalStorage";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export default function EditAlertRulePage() {
const t = useTranslations();
const params = useParams();
const router = useRouter();
const orgId = params.orgId as string;
const ruleId = params.ruleId as string;
const [rule, setRule] = useState<AlertRule | null | undefined>(undefined);
useEffect(() => {
const r = getRule(orgId, ruleId);
setRule(r ?? null);
}, [orgId, ruleId]);
useEffect(() => {
if (rule === null) {
router.replace(`/${orgId}/settings/alerting`);
}
}, [rule, orgId, router]);
if (rule === undefined) {
return (
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
{t("loading")}
</div>
);
}
if (rule === null) {
return null;
}
return (
<AlertRuleGraphEditor
key={rule.id}
orgId={orgId}
ruleId={rule.id}
createdAt={rule.createdAt}
initialValues={ruleToFormValues(rule)}
isNew={false}
/>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import { defaultFormValues } from "@app/lib/alertRuleForm";
import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export default function NewAlertRulePage() {
const t = useTranslations();
const params = useParams();
const orgId = params.orgId as string;
const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>(
null
);
useEffect(() => {
setMeta({ id: newRuleId(), createdAt: isoNow() });
}, []);
if (!meta) {
return (
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
{t("loading")}
</div>
);
}
return (
<AlertRuleGraphEditor
key={meta.id}
orgId={orgId}
ruleId={meta.id}
createdAt={meta.createdAt}
initialValues={defaultFormValues()}
isNew
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -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 = { type ProvisioningPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function ProvisioningPage(props: ProvisioningPageProps) { export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params; 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} />
</>
);
} }

View File

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

View File

@@ -18,7 +18,6 @@ export default async function SitesPage(props: SitesPageProps) {
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let sites: ListSitesResponse["sites"] = []; let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = { let pagination: ListSitesResponse["pagination"] = {

View File

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

View File

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

View File

@@ -79,8 +79,7 @@ export default function CreateSiteProvisioningKeyCredenza({
.max(1_000_000, { .max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid") message: t("provisioningKeysMaxBatchSizeInvalid")
}), }),
validUntil: z.string().optional(), validUntil: z.string().optional()
approveNewSites: z.boolean()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -104,8 +103,7 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "", validUntil: ""
approveNewSites: true
} }
}); });
@@ -116,8 +114,7 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "", validUntil: ""
approveNewSites: true
}); });
} }
}, [open, form]); }, [open, form]);
@@ -126,21 +123,18 @@ export default function CreateSiteProvisioningKeyCredenza({
setLoading(true); setLoading(true);
try { try {
const res = await api const res = await api
.put<AxiosResponse<CreateSiteProvisioningKeyResponse>>( .put<
`/org/${orgId}/site-provisioning-key`, AxiosResponse<CreateSiteProvisioningKeyResponse>
{ >(`/org/${orgId}/site-provisioning-key`, {
name: data.name, name: data.name,
maxBatchSize: data.unlimitedBatchSize maxBatchSize: data.unlimitedBatchSize
? null ? null
: data.maxBatchSize, : data.maxBatchSize,
validUntil: validUntil:
data.validUntil == null || data.validUntil == null || data.validUntil.trim() === ""
data.validUntil.trim() === ""
? undefined ? undefined
: data.validUntil, : data.validUntil
approveNewSites: data.approveNewSites })
}
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", 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"); const unlimitedBatchSize = form.watch("unlimitedBatchSize");
@@ -217,12 +213,15 @@ export default function CreateSiteProvisioningKeyCredenza({
min={1} min={1}
max={1_000_000} max={1_000_000}
autoComplete="off" autoComplete="off"
disabled={unlimitedBatchSize} disabled={
unlimitedBatchSize
}
name={field.name} name={field.name}
ref={field.ref} ref={field.ref}
onBlur={field.onBlur} onBlur={field.onBlur}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v =
e.target.value;
field.onChange( field.onChange(
v === "" v === ""
? 100 ? 100
@@ -270,7 +269,9 @@ export default function CreateSiteProvisioningKeyCredenza({
const dateTimeValue: DateTimeValue = const dateTimeValue: DateTimeValue =
(() => { (() => {
if (!field.value) return {}; if (!field.value) return {};
const d = new Date(field.value); const d = new Date(
field.value
);
if (isNaN(d.getTime())) if (isNaN(d.getTime()))
return {}; return {};
const hours = d const hours = d
@@ -312,7 +313,11 @@ export default function CreateSiteProvisioningKeyCredenza({
value.date value.date
); );
if (value.time) { if (value.time) {
const [h, m, s] = const [
h,
m,
s
] =
value.time.split( 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>
</Form> </Form>
)} )}

View File

@@ -45,7 +45,6 @@ export type EditableSiteProvisioningKey = {
name: string; name: string;
maxBatchSize: number | null; maxBatchSize: number | null;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
type EditSiteProvisioningKeyCredenzaProps = { type EditSiteProvisioningKeyCredenzaProps = {
@@ -77,8 +76,7 @@ export default function EditSiteProvisioningKeyCredenza({
.max(1_000_000, { .max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid") message: t("provisioningKeysMaxBatchSizeInvalid")
}), }),
validUntil: z.string().optional(), validUntil: z.string().optional()
approveNewSites: z.boolean()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -102,8 +100,7 @@ export default function EditSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "", validUntil: ""
approveNewSites: true
} }
}); });
@@ -115,8 +112,7 @@ export default function EditSiteProvisioningKeyCredenza({
name: provisioningKey.name, name: provisioningKey.name,
unlimitedBatchSize: provisioningKey.maxBatchSize == null, unlimitedBatchSize: provisioningKey.maxBatchSize == null,
maxBatchSize: provisioningKey.maxBatchSize ?? 100, maxBatchSize: provisioningKey.maxBatchSize ?? 100,
validUntil: provisioningKey.validUntil ?? "", validUntil: provisioningKey.validUntil ?? ""
approveNewSites: provisioningKey.approveNewSites
}); });
}, [open, provisioningKey, form]); }, [open, provisioningKey, form]);
@@ -139,8 +135,7 @@ export default function EditSiteProvisioningKeyCredenza({
data.validUntil == null || data.validUntil == null ||
data.validUntil.trim() === "" data.validUntil.trim() === ""
? "" ? ""
: data.validUntil, : data.validUntil
approveNewSites: data.approveNewSites
} }
) )
.catch((e) => { .catch((e) => {
@@ -260,38 +255,6 @@ export default function EditSiteProvisioningKeyCredenza({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="validUntil" name="validUntil"

View File

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

View File

@@ -0,0 +1,981 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { TagInput } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
type AlertRuleFormAction,
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce";
export function DropdownAddAction({
onPick
}: {
onPick: (type: "notify" | "sms" | "webhook") => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddAction")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-2" align="end">
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("notify");
setOpen(false);
}}
>
{t("alertingActionNotify")}
</Button>
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("sms");
setOpen(false);
}}
>
{t("alertingActionSms")}
</Button>
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("webhook");
setOpen(false);
}}
>
{t("alertingActionWebhook")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function SiteMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [debounced] = useDebounce(q, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({ orgId, query: debounced, perPage: 500 })
);
const toggle = (id: number) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectSites")
: t("alertingSitesSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={q}
onValueChange={setQ}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sites.map((s) => (
<CommandItem
key={s.siteId}
value={`${s.siteId}`}
onSelect={() => toggle(s.siteId)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(s.siteId)}
className="mr-2 pointer-events-none"
/>
{s.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const ALERT_RESOURCES_PAGE_SIZE = 10;
function ResourceTenMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const { data: resources = [] } = useQuery(
orgQueries.resources({
orgId,
perPage: ALERT_RESOURCES_PAGE_SIZE
})
);
const rows = useMemo(() => {
const out: {
resourceId: number;
name: string;
targetIds: number[];
}[] = [];
for (const r of resources) {
const targetIds = r.targets.map((x) => x.targetId);
if (targetIds.length > 0) {
out.push({
resourceId: r.resourceId,
name: r.name,
targetIds
});
}
}
return out;
}, [resources]);
const selectedResourceCount = useMemo(
() =>
rows.filter(
(row) =>
row.targetIds.length > 0 &&
row.targetIds.every((id) => value.includes(id))
).length,
[rows, value]
);
const toggle = (targetIds: number[]) => {
const allOn =
targetIds.length > 0 &&
targetIds.every((id) => value.includes(id));
if (allOn) {
onChange(value.filter((id) => !targetIds.includes(id)));
} else {
onChange([...new Set([...value, ...targetIds])]);
}
};
const summary =
selectedResourceCount === 0
? t("alertingSelectResources")
: t("alertingResourcesSelected", {
count: selectedResourceCount
});
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<div className="max-h-72 overflow-y-auto p-2 space-y-0.5">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground px-2 py-1.5">
{t("alertingResourcesEmpty")}
</p>
) : (
rows.map((row) => {
const checked =
row.targetIds.length > 0 &&
row.targetIds.every((id) =>
value.includes(id)
);
return (
<button
key={row.resourceId}
type="button"
onClick={() => toggle(row.targetIds)}
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
>
<Checkbox
checked={checked}
className="pointer-events-none shrink-0"
aria-hidden
/>
<span className="truncate">
{row.name}
</span>
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
export function ActionBlock({
orgId,
index,
control,
form,
onRemove,
canRemove
}: {
orgId: string;
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
onRemove: () => void;
canRemove: boolean;
}) {
const t = useTranslations();
const type = form.watch(`actions.${index}.type`);
return (
<div className="rounded-lg border p-4 space-y-3 relative">
{canRemove && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
onClick={onRemove}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
<FormField
control={control}
name={`actions.${index}.type`}
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingActionType")}</FormLabel>
<Select
value={field.value}
onValueChange={(v) => {
const nt = v as AlertRuleFormAction["type"];
if (nt === "notify") {
form.setValue(`actions.${index}`, {
type: "notify",
userIds: [],
roleIds: [],
emailTags: []
});
} else if (nt === "sms") {
form.setValue(`actions.${index}`, {
type: "sms",
phoneTags: []
});
} else {
form.setValue(`actions.${index}`, {
type: "webhook",
url: "",
method: "POST",
headers: [{ key: "", value: "" }],
secret: ""
});
}
}}
>
<FormControl>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="notify">
{t("alertingActionNotify")}
</SelectItem>
<SelectItem value="sms">
{t("alertingActionSms")}
</SelectItem>
<SelectItem value="webhook">
{t("alertingActionWebhook")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{type === "notify" && (
<NotifyActionFields
orgId={orgId}
index={index}
control={control}
form={form}
/>
)}
{type === "sms" && (
<SmsActionFields index={index} control={control} form={form} />
)}
{type === "webhook" && (
<WebhookActionFields
index={index}
control={control}
form={form}
/>
)}
</div>
);
}
function NotifyActionFields({
orgId,
index,
control,
form
}: {
orgId: string;
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const userIds = form.watch(`actions.${index}.userIds`) ?? [];
const roleIds = form.watch(`actions.${index}.roleIds`) ?? [];
const emailTags = form.watch(`actions.${index}.emailTags`) ?? [];
return (
<div className="space-y-3 pt-1">
<FormItem>
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<UserMultiSelect
orgId={orgId}
value={userIds}
onChange={(ids) =>
form.setValue(`actions.${index}.userIds`, ids)
}
/>
</FormItem>
<FormItem>
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<RoleMultiSelect
orgId={orgId}
value={roleIds}
onChange={(ids) =>
form.setValue(`actions.${index}.roleIds`, ids)
}
/>
</FormItem>
<FormField
control={control}
name={`actions.${index}.emailTags`}
render={() => (
<FormItem>
<FormLabel>{t("alertingNotifyEmails")}</FormLabel>
<FormControl>
<TagInput
tags={emailTags}
setTags={(updater) => {
const next =
typeof updater === "function"
? updater(emailTags)
: updater;
form.setValue(
`actions.${index}.emailTags`,
next
);
}}
activeTagIndex={emailActiveIdx}
setActiveTagIndex={setEmailActiveIdx}
placeholder={t("alertingEmailPlaceholder")}
delimiterList={[",", "Enter"]}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
}
function SmsActionFields({
index,
control,
form
}: {
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
const [phoneActiveIdx, setPhoneActiveIdx] = useState<number | null>(null);
const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? [];
return (
<FormField
control={control}
name={`actions.${index}.phoneTags`}
render={() => (
<FormItem>
<FormLabel>{t("alertingSmsNumbers")}</FormLabel>
<FormControl>
<TagInput
tags={phoneTags}
setTags={(updater) => {
const next =
typeof updater === "function"
? updater(phoneTags)
: updater;
form.setValue(
`actions.${index}.phoneTags`,
next
);
}}
activeTagIndex={phoneActiveIdx}
setActiveTagIndex={setPhoneActiveIdx}
placeholder={t("alertingSmsPlaceholder")}
delimiterList={[",", "Enter"]}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
function WebhookActionFields({
index,
control,
form
}: {
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
return (
<div className="space-y-3 pt-1">
<FormField
control={control}
name={`actions.${index}.url`}
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/hook"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`actions.${index}.method`}
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingWebhookMethod")}</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{["GET", "POST", "PUT", "PATCH"].map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`actions.${index}.secret`}
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingWebhookSecret")}</FormLabel>
<FormControl>
<Input
{...field}
type="password"
autoComplete="new-password"
placeholder={t(
"alertingWebhookSecretPlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<WebhookHeadersField index={index} control={control} form={form} />
</div>
);
}
function WebhookHeadersField({
index,
control,
form
}: {
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
const headers =
form.watch(`actions.${index}.headers` as const) ?? [];
return (
<div className="space-y-2">
<FormLabel>{t("alertingWebhookHeaders")}</FormLabel>
{headers.map((_, hi) => (
<div key={hi} className="flex gap-2 items-start">
<FormField
control={control}
name={`actions.${index}.headers.${hi}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="Key" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`actions.${index}.headers.${hi}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="Value" />
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
const cur =
form.getValues(
`actions.${index}.headers`
) ?? [];
form.setValue(
`actions.${index}.headers`,
cur.filter((__, i) => i !== hi)
);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const cur =
form.getValues(`actions.${index}.headers`) ?? [];
form.setValue(`actions.${index}.headers`, [
...cur,
{ key: "", value: "" }
]);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddHeader")}
</Button>
</div>
);
}
function UserMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: string[];
onChange: (v: string[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [debounced] = useDebounce(q, 150);
const { data: users = [] } = useQuery(orgQueries.users({ orgId }));
const shown = useMemo(() => {
const qq = debounced.trim().toLowerCase();
if (!qq) return users.slice(0, 200);
return users
.filter((u) => {
const label = getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
}).toLowerCase();
return (
label.includes(qq) ||
(u.email ?? "").toLowerCase().includes(qq)
);
})
.slice(0, 200);
}, [users, debounced]);
const toggle = (id: string) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectUsers")
: t("alertingUsersSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchPlaceholder")}
value={q}
onValueChange={setQ}
/>
<CommandList>
<CommandEmpty>{t("noResults")}</CommandEmpty>
<CommandGroup>
{shown.map((u) => {
const uid = String(u.id);
return (
<CommandItem
key={uid}
value={uid}
onSelect={() => toggle(uid)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(uid)}
className="mr-2 pointer-events-none"
/>
{getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function RoleMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const { data: roles = [] } = useQuery(orgQueries.roles({ orgId }));
const toggle = (id: number) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectRoles")
: t("alertingRolesSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandList>
<CommandGroup>
{roles.map((r) => (
<CommandItem
key={r.roleId}
value={`role-${r.roleId}`}
onSelect={() => toggle(r.roleId)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(r.roleId)}
className="mr-2 pointer-events-none"
/>
{r.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function AlertRuleSourceFields({
orgId,
control
}: {
orgId: string;
control: Control<AlertRuleFormValues>;
}) {
const t = useTranslations();
const { setValue, getValues } = useFormContext<AlertRuleFormValues>();
const sourceType = useWatch({ control, name: "sourceType" });
return (
<div className="space-y-4">
<FormField
control={control}
name="sourceType"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingSourceType")}</FormLabel>
<Select
value={field.value}
onValueChange={(v) => {
const next = v as AlertRuleFormValues["sourceType"];
field.onChange(next);
const curTrigger = getValues("trigger");
if (next === "site") {
if (
curTrigger !== "site_online" &&
curTrigger !== "site_offline"
) {
setValue("trigger", "site_offline", {
shouldValidate: true
});
}
} else if (
curTrigger !== "health_check_healthy" &&
curTrigger !== "health_check_unhealthy"
) {
setValue(
"trigger",
"health_check_unhealthy",
{ shouldValidate: true }
);
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="site">
{t("alertingSourceSite")}
</SelectItem>
<SelectItem value="health_check">
{t("alertingSourceHealthCheck")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{sourceType === "site" ? (
<FormField
control={control}
name="siteIds"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingPickSites")}</FormLabel>
<SiteMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={control}
name="targetIds"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingPickResources")}</FormLabel>
<ResourceTenMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
);
}
export function AlertRuleTriggerFields({
control
}: {
control: Control<AlertRuleFormValues>;
}) {
const t = useTranslations();
const sourceType = useWatch({ control, name: "sourceType" });
return (
<FormField
control={control}
name="trigger"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingTrigger")}</FormLabel>
<Select
key={sourceType}
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{sourceType === "site" ? (
<>
<SelectItem value="site_online">
{t("alertingTriggerSiteOnline")}
</SelectItem>
<SelectItem value="site_offline">
{t("alertingTriggerSiteOffline")}
</SelectItem>
</>
) : (
<>
<SelectItem value="health_check_healthy">
{t("alertingTriggerHcHealthy")}
</SelectItem>
<SelectItem value="health_check_unhealthy">
{t("alertingTriggerHcUnhealthy")}
</SelectItem>
</>
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,715 @@
"use client";
import {
ActionBlock,
AlertRuleSourceFields,
AlertRuleTriggerFields,
DropdownAddAction
} from "@app/components/alert-rule-editor/AlertRuleFields";
import { SettingsContainer } from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import {
buildFormSchema,
defaultFormValues,
formValuesToRule,
type AlertRuleFormAction,
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
import { upsertRule } from "@app/lib/alertRulesLocalStorage";
import { cn } from "@app/lib/cn";
import {
Background,
Handle,
Position,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
type Edge,
type Node,
type NodeProps,
type NodeTypes
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { useTranslations } from "next-intl";
type AlertRuleT = ReturnType<typeof useTranslations>;
export type AlertStepId = "source" | "trigger" | "actions";
type AlertStepNodeData = {
roleLabel: string;
title: string;
subtitle: string;
configured: boolean;
accent: string;
topBorderClass: string;
};
function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
if (v.sourceType === "site") {
if (v.siteIds.length === 0) {
return t("alertingNodeNotConfigured");
}
return t("alertingSummarySites", { count: v.siteIds.length });
}
if (v.targetIds.length === 0) {
return t("alertingNodeNotConfigured");
}
return t("alertingSummaryHealthChecks", { count: v.targetIds.length });
}
function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
switch (v.trigger) {
case "site_online":
return t("alertingTriggerSiteOnline");
case "site_offline":
return t("alertingTriggerSiteOffline");
case "health_check_healthy":
return t("alertingTriggerHcHealthy");
case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy");
default:
return v.trigger;
}
}
function oneActionConfigured(a: AlertRuleFormAction): boolean {
if (a.type === "notify") {
return (
a.userIds.length > 0 ||
a.roleIds.length > 0 ||
a.emailTags.length > 0
);
}
if (a.type === "sms") {
return a.phoneTags.length > 0;
}
try {
new URL(a.url.trim());
return true;
} catch {
return false;
}
}
function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
switch (a.type) {
case "notify":
return t("alertingActionNotify");
case "sms":
return t("alertingActionSms");
case "webhook":
return t("alertingActionWebhook");
}
}
function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
if (a.type === "notify") {
if (
a.userIds.length === 0 &&
a.roleIds.length === 0 &&
a.emailTags.length === 0
) {
return t("alertingNodeNotConfigured");
}
const parts: string[] = [];
if (a.userIds.length > 0) {
parts.push(t("alertingUsersSelected", { count: a.userIds.length }));
}
if (a.roleIds.length > 0) {
parts.push(t("alertingRolesSelected", { count: a.roleIds.length }));
}
if (a.emailTags.length > 0) {
parts.push(
`${t("alertingNotifyEmails")} (${a.emailTags.length})`
);
}
return parts.join(" · ");
}
if (a.type === "sms") {
if (a.phoneTags.length === 0) {
return t("alertingNodeNotConfigured");
}
return `${t("alertingSmsNumbers")}: ${a.phoneTags.length}`;
}
const url = a.url.trim();
if (!url) {
return t("alertingNodeNotConfigured");
}
try {
return new URL(url).hostname;
} catch {
return t("alertingNodeNotConfigured");
}
}
function stepConfigured(
step: "source" | "trigger",
v: AlertRuleFormValues
): boolean {
if (step === "source") {
return v.sourceType === "site"
? v.siteIds.length > 0
: v.targetIds.length > 0;
}
return Boolean(v.trigger);
}
function buildActionStepNodeData(
index: number,
action: AlertRuleFormAction,
t: AlertRuleT
): AlertStepNodeData {
return {
roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`,
title: actionTypeLabel(action, t),
subtitle: summarizeOneAction(action, t),
configured: oneActionConfigured(action),
accent: "text-amber-600 dark:text-amber-400",
topBorderClass: "border-t-amber-500"
};
}
function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData {
return {
roleLabel: t("alertingNodeRoleAction"),
title: t("alertingSectionActions"),
subtitle: t("alertingNodeNotConfigured"),
configured: false,
accent: "text-amber-600 dark:text-amber-400",
topBorderClass: "border-t-amber-500"
};
}
const AlertStepNode = memo(function AlertStepNodeFn({
data,
selected
}: NodeProps<Node<AlertStepNodeData>>) {
return (
<div
className={cn(
"relative rounded-xl border-2 border-t-[3px] bg-card px-5 py-4 shadow-sm min-w-[260px] max-w-[320px] transition-shadow",
data.topBorderClass,
selected
? "border-primary ring-2 ring-primary/25 shadow-md"
: "border-border"
)}
>
<Handle
type="target"
position={Position.Top}
className="!bg-muted-foreground !w-2 !h-2"
/>
{data.configured && (
<Check
className="absolute top-3 right-3 h-5 w-5 text-green-600"
aria-hidden
/>
)}
<p
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
data.accent
)}
>
{data.roleLabel}
</p>
<p className="font-semibold text-base mt-1">{data.title}</p>
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-3 leading-snug">
{data.subtitle}
</p>
<Handle
type="source"
position={Position.Bottom}
className="!bg-muted-foreground !w-2 !h-2"
/>
</div>
);
});
const nodeTypes: NodeTypes = {
alertStep: AlertStepNode
};
const ACTION_NODE_X_GAP = 280;
const ACTION_NODE_Y = 468;
const SOURCE_NODE_POS = { x: 120, y: 28 };
const TRIGGER_NODE_POS = { x: 120, y: 248 };
function buildNodeData(
stepId: "source" | "trigger",
v: AlertRuleFormValues,
t: AlertRuleT
): AlertStepNodeData {
const accents: Record<
"source" | "trigger",
{ accent: string; topBorderClass: string; role: string; title: string }
> = {
source: {
accent: "text-blue-600 dark:text-blue-400",
topBorderClass: "border-t-blue-500",
role: t("alertingNodeRoleSource"),
title: t("alertingSectionSource")
},
trigger: {
accent: "text-emerald-600 dark:text-emerald-400",
topBorderClass: "border-t-emerald-500",
role: t("alertingNodeRoleTrigger"),
title: t("alertingSectionTrigger")
}
};
const meta = accents[stepId];
const subtitle =
stepId === "source"
? summarizeSource(v, t)
: summarizeTrigger(v, t);
return {
roleLabel: meta.role,
title: meta.title,
subtitle,
configured: stepConfigured(stepId, v),
accent: meta.accent,
topBorderClass: meta.topBorderClass
};
}
type AlertRuleGraphEditorProps = {
orgId: string;
ruleId: string;
createdAt: string;
initialValues: AlertRuleFormValues;
isNew: boolean;
};
const FORM_ID = "alert-rule-graph-form";
export default function AlertRuleGraphEditor({
orgId,
ruleId,
createdAt,
initialValues,
isNew
}: AlertRuleGraphEditorProps) {
const t = useTranslations();
const router = useRouter();
const schema = useMemo(() => buildFormSchema(t), [t]);
const form = useForm<AlertRuleFormValues>({
resolver: zodResolver(schema),
defaultValues: initialValues ?? defaultFormValues()
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "actions"
});
const wName = useWatch({ control: form.control, name: "name" }) ?? "";
const wEnabled =
useWatch({ control: form.control, name: "enabled" }) ?? true;
const wSourceType =
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
const wSiteIds =
useWatch({ control: form.control, name: "siteIds" }) ?? [];
const wTargetIds =
useWatch({ control: form.control, name: "targetIds" }) ?? [];
const wTrigger =
useWatch({ control: form.control, name: "trigger" }) ??
"site_offline";
const wActions =
useWatch({ control: form.control, name: "actions" }) ?? [];
const flowValues: AlertRuleFormValues = useMemo(
() => ({
name: wName,
enabled: wEnabled,
sourceType: wSourceType,
siteIds: wSiteIds,
targetIds: wTargetIds,
trigger: wTrigger,
actions: wActions
}),
[
wName,
wEnabled,
wSourceType,
wSiteIds,
wTargetIds,
wTrigger,
wActions
]
);
const [selectedStep, setSelectedStep] = useState<string>("source");
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const nodesSyncKeyRef = useRef("");
useEffect(() => {
const key = JSON.stringify({ flowValues, selectedStep });
if (key === nodesSyncKeyRef.current) {
return;
}
nodesSyncKeyRef.current = key;
const nActions = flowValues.actions.length;
const actionNodes: Node[] =
nActions === 0
? [
{
id: "actions",
type: "alertStep",
position: {
x: TRIGGER_NODE_POS.x,
y: ACTION_NODE_Y
},
data: buildActionsPlaceholderNodeData(t),
selected:
selectedStep === "actions" ||
selectedStep.startsWith("action-")
}
]
: flowValues.actions.map((action, i) => {
const totalWidth =
(nActions - 1) * ACTION_NODE_X_GAP;
const originX =
TRIGGER_NODE_POS.x - totalWidth / 2;
return {
id: `action-${i}`,
type: "alertStep",
position: {
x: originX + i * ACTION_NODE_X_GAP,
y: ACTION_NODE_Y
},
data: buildActionStepNodeData(i, action, t),
selected: selectedStep === `action-${i}`
};
});
setNodes([
{
id: "source",
type: "alertStep",
position: SOURCE_NODE_POS,
data: buildNodeData("source", flowValues, t),
selected: selectedStep === "source"
},
{
id: "trigger",
type: "alertStep",
position: TRIGGER_NODE_POS,
data: buildNodeData("trigger", flowValues, t),
selected: selectedStep === "trigger"
},
...actionNodes
]);
const nextEdges: Edge[] = [
{
id: "e-src-trg",
source: "source",
target: "trigger",
animated: true
},
...(nActions === 0
? [
{
id: "e-trg-act",
source: "trigger",
target: "actions",
animated: true
} as const
]
: flowValues.actions.map((_, i) => ({
id: `e-trg-act-${i}`,
source: "trigger",
target: `action-${i}`,
animated: true
})))
];
setEdges(nextEdges);
}, [flowValues, selectedStep, t, setNodes, setEdges]);
useEffect(() => {
if (selectedStep === "actions" && wActions.length > 0) {
setSelectedStep("action-0");
}
}, [selectedStep, wActions.length]);
useEffect(() => {
if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) {
setSelectedStep("actions");
}
}, [wActions.length, selectedStep]);
useEffect(() => {
const m = /^action-(\d+)$/.exec(selectedStep);
if (!m) {
return;
}
const i = Number(m[1], 10);
if (i >= wActions.length) {
setSelectedStep(
wActions.length > 0
? `action-${wActions.length - 1}`
: "actions"
);
}
}, [wActions.length, selectedStep]);
const onNodeClick = useCallback((_event: unknown, node: Node) => {
setSelectedStep(node.id);
}, []);
const onSubmit = form.handleSubmit((values) => {
const next = formValuesToRule(values, ruleId, createdAt);
upsertRule(orgId, next);
toast({ title: t("alertingRuleSaved") });
if (isNew) {
router.replace(`/${orgId}/settings/alerting/${ruleId}`);
}
});
const isActionsSidebar =
selectedStep === "actions" || selectedStep.startsWith("action-");
const sidebarTitle = isActionsSidebar
? t("alertingConfigureActions")
: selectedStep === "source"
? t("alertingConfigureSource")
: t("alertingConfigureTrigger");
return (
<Form {...form}>
<form id={FORM_ID} onSubmit={onSubmit}>
<SettingsContainer>
<Card>
<CardContent className="p-4 sm:p-5 space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:flex-wrap md:items-center">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/${orgId}/settings/alerting`}>
<ChevronLeft className="h-4 w-4 mr-1" />
{t("alertingBackToRules")}
</Link>
</Button>
{isNew && (
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
{t("alertingDraftBadge")}
</span>
)}
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1 min-w-0 md:min-w-[12rem] md:max-w-md">
<FormLabel className="sr-only">
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"alertingRuleNamePlaceholder"
)}
className="font-medium"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-wrap items-center gap-3 md:ml-auto">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormLabel className="text-sm font-normal cursor-pointer whitespace-nowrap">
{t("alertingRuleEnabled")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit">
{t("save")}
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<Card className="flex flex-col w-full overflow-hidden">
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
<CardTitle className="text-lg font-bold tracking-tight">
{t("alertingGraphCanvasTitle")}
</CardTitle>
<CardDescription className="pt-0">
{t("alertingGraphCanvasDescription")}
</CardDescription>
</CardHeader>
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
<div
className={cn(
"rounded-md border bg-muted/30 overflow-hidden",
"min-h-[min(66vh,560px)] lg:min-h-[680px]"
)}
>
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{
padding: 0.35
}}
minZoom={0.5}
maxZoom={1.25}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
panOnScroll
zoomOnScroll
proOptions={{
hideAttribution: true
}}
className="bg-transparent !h-full !w-full min-h-[min(66vh,560px)] lg:min-h-[680px]"
>
<Background gap={16} size={1} />
</ReactFlow>
</ReactFlowProvider>
</div>
</CardContent>
</Card>
<Card className="flex flex-col w-full">
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
<CardTitle className="text-lg font-bold tracking-tight">
{sidebarTitle}
</CardTitle>
<CardDescription className="pt-0">
{t("alertingSidebarHint")}
</CardDescription>
</CardHeader>
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
<div className="space-y-6">
{selectedStep === "source" && (
<AlertRuleSourceFields
orgId={orgId}
control={form.control}
/>
)}
{selectedStep === "trigger" && (
<AlertRuleTriggerFields
control={form.control}
/>
)}
{isActionsSidebar && (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">
{t(
"alertingSectionActions"
)}
</span>
<DropdownAddAction
onPick={(type) => {
const newIndex =
fields.length;
if (type === "notify") {
append({
type: "notify",
userIds: [],
roleIds: [],
emailTags: []
});
} else if (
type === "sms"
) {
append({
type: "sms",
phoneTags: []
});
} else {
append({
type: "webhook",
url: "",
method: "POST",
headers: [
{
key: "",
value: ""
}
],
secret: ""
});
}
setSelectedStep(
`action-${newIndex}`
);
}}
/>
</div>
{fields.map((f, index) => (
<ActionBlock
key={f.id}
orgId={orgId}
index={index}
control={form.control}
form={form}
onRemove={() =>
remove(index)
}
canRemove
/>
))}
</div>
)}
</div>
</CardContent>
</Card>
</div>
</SettingsContainer>
</form>
</Form>
);
}

283
src/lib/alertRuleForm.ts Normal file
View File

@@ -0,0 +1,283 @@
import type { Tag } from "@app/components/tags/tag-input";
import {
type AlertRule,
type AlertTrigger,
isoNow,
type AlertAction as StoredAlertAction
} from "@app/lib/alertRulesLocalStorage";
import { z } from "zod";
export const tagSchema = z.object({
id: z.string(),
text: z.string()
});
export type AlertRuleFormAction =
| {
type: "notify";
userIds: string[];
roleIds: number[];
emailTags: Tag[];
}
| { type: "sms"; phoneTags: Tag[] }
| {
type: "webhook";
url: string;
method: string;
headers: { key: string; value: string }[];
secret: string;
};
export type AlertRuleFormValues = {
name: string;
enabled: boolean;
sourceType: "site" | "health_check";
siteIds: number[];
targetIds: number[];
trigger: AlertTrigger;
actions: AlertRuleFormAction[];
};
export function buildFormSchema(t: (k: string) => string) {
return z
.object({
name: z.string().min(1, { message: t("alertingErrorNameRequired") }),
enabled: z.boolean(),
sourceType: z.enum(["site", "health_check"]),
siteIds: z.array(z.number()),
targetIds: z.array(z.number()),
trigger: z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]),
actions: z
.array(
z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
emailTags: z.array(tagSchema)
}),
z.object({
type: z.literal("sms"),
phoneTags: z.array(tagSchema)
}),
z.object({
type: z.literal("webhook"),
url: z.string(),
method: z.string(),
headers: z.array(
z.object({
key: z.string(),
value: z.string()
})
),
secret: z.string()
})
])
)
})
.superRefine((val, ctx) => {
if (val.actions.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorActionsMin"),
path: ["actions"]
});
}
if (val.sourceType === "site" && val.siteIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickSites"),
path: ["siteIds"]
});
}
if (
val.sourceType === "health_check" &&
val.targetIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickHealthChecks"),
path: ["targetIds"]
});
}
const siteTriggers: AlertTrigger[] = [
"site_online",
"site_offline"
];
const hcTriggers: AlertTrigger[] = [
"health_check_healthy",
"health_check_unhealthy"
];
if (
val.sourceType === "site" &&
!siteTriggers.includes(val.trigger)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorTriggerSite"),
path: ["trigger"]
});
}
if (
val.sourceType === "health_check" &&
!hcTriggers.includes(val.trigger)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorTriggerHealth"),
path: ["trigger"]
});
}
val.actions.forEach((a, i) => {
if (a.type === "notify") {
if (
a.userIds.length === 0 &&
a.roleIds.length === 0 &&
a.emailTags.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorNotifyRecipients"),
path: ["actions", i, "userIds"]
});
}
}
if (a.type === "sms" && a.phoneTags.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorSmsPhones"),
path: ["actions", i, "phoneTags"]
});
}
if (a.type === "webhook") {
try {
// eslint-disable-next-line no-new
new URL(a.url.trim());
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorWebhookUrl"),
path: ["actions", i, "url"]
});
}
}
});
});
}
export function defaultFormValues(): AlertRuleFormValues {
return {
name: "",
enabled: true,
sourceType: "site",
siteIds: [],
targetIds: [],
trigger: "site_offline",
actions: [
{
type: "notify",
userIds: [],
roleIds: [],
emailTags: []
}
]
};
}
export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues {
const actions: AlertRuleFormAction[] = rule.actions.map(
(a: StoredAlertAction) => {
if (a.type === "notify") {
return {
type: "notify",
userIds: a.userIds.map(String),
roleIds: [...a.roleIds],
emailTags: a.emails.map((e) => ({ id: e, text: e }))
};
}
if (a.type === "sms") {
return {
type: "sms",
phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p }))
};
}
return {
type: "webhook",
url: a.url,
method: a.method,
headers:
a.headers.length > 0
? a.headers.map((h) => ({ ...h }))
: [{ key: "", value: "" }],
secret: a.secret ?? ""
};
}
);
return {
name: rule.name,
enabled: rule.enabled,
sourceType: rule.source.type,
siteIds:
rule.source.type === "site" ? [...rule.source.siteIds] : [],
targetIds:
rule.source.type === "health_check"
? [...rule.source.targetIds]
: [],
trigger: rule.trigger,
actions
};
}
export function formValuesToRule(
v: AlertRuleFormValues,
id: string,
createdAt: string
): AlertRule {
const source =
v.sourceType === "site"
? { type: "site" as const, siteIds: v.siteIds }
: {
type: "health_check" as const,
targetIds: v.targetIds
};
const actions = v.actions.map((a) => {
if (a.type === "notify") {
return {
type: "notify" as const,
userIds: a.userIds,
roleIds: a.roleIds,
emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean)
};
}
if (a.type === "sms") {
return {
type: "sms" as const,
phoneNumbers: a.phoneTags
.map((tg) => tg.text.trim())
.filter(Boolean)
};
}
return {
type: "webhook" as const,
url: a.url.trim(),
method: a.method,
headers: a.headers.filter((h) => h.key.trim() || h.value.trim()),
secret: a.secret.trim() || undefined
};
});
return {
id,
name: v.name.trim(),
enabled: v.enabled,
createdAt,
updatedAt: isoNow(),
source,
trigger: v.trigger,
actions
};
}

View File

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