Compare commits

..

217 Commits

Author SHA1 Message Date
Mauricio Siu
ad3b2b9b21 Merge branch 'canary' into feat/add-whitelabelling 2026-03-09 00:32:23 -06:00
Mauricio Siu
6e67864204 Merge pull request #3951 from Dokploy/3948-unhandled-rejection-in-gettrustedorigins-crashes-server-on-db-connection-failure
fix: add error handling to trusted origins retrieval in admin service
2026-03-08 23:52:54 -06:00
Mauricio Siu
2102840bb9 fix: add error handling to trusted origins retrieval in admin service 2026-03-08 23:48:51 -06:00
Mauricio Siu
30f061e774 Merge pull request #3947 from Dokploy/3896-application-monitor-problem
fix: enhance container metrics query to support wildcard matching for…
2026-03-08 16:17:14 -06:00
Mauricio Siu
c00aa6acbf fix: enhance container metrics query to support wildcard matching for container names 2026-03-08 16:16:45 -06:00
Mauricio Siu
8e9ab98a7a Merge pull request #3940 from Dokploy/3806-bug-traefik-and-dokploy-fails-to-start-when-port-8080-is-already-in-use-service-crash
fix: improve port conflict detection by enhancing error messages and …
2026-03-08 03:09:18 -06:00
Mauricio Siu
ce82e2322b fix: improve port conflict detection by enhancing error messages and adding host-level service checks 2026-03-08 03:08:38 -06:00
Mauricio Siu
ec7df05990 Merge pull request #3939 from Dokploy/3827-bulk-deploy-fails-silently-when-deploying-from-docker-image
fix: update success message for service deployment to reflect queued …
2026-03-08 02:53:11 -06:00
Mauricio Siu
75a4e8e8ef fix: update success message for service deployment to reflect queued status 2026-03-08 02:52:46 -06:00
Mauricio Siu
b4319c7ea2 Bump version from v0.28.4 to v0.28.5 2026-03-08 02:46:55 -06:00
Mauricio Siu
e9787b753d Merge pull request #3934 from Dokploy/feat/use-appname-on-backups-folder
Feat/use appname on backups folder
2026-03-07 23:44:08 -06:00
Mauricio Siu
b419294b09 fix: add --drop option to mongorestore command for improved data restoration https://github.com/Dokploy/dokploy/issues/2713 2026-03-07 23:38:58 -06:00
Mauricio Siu
922b4d58f1 refactor: enhance backup functionality by incorporating appName and serviceName for S3 bucket paths 2026-03-07 23:32:41 -06:00
Mauricio Siu
dc8ff78ee5 Merge pull request #3931 from Dokploy/3928-foreign-key-constraint-violation-on-git_provider-during-github-setup-userid-is-empty---v0284
refactor: replace authClient with api.user.session.useQuery in multip…
2026-03-07 18:23:29 -06:00
Mauricio Siu
735c9952d8 chore: import authClient in show-users component for enhanced authentication handling 2026-03-07 18:14:30 -06:00
Mauricio Siu
21821295e3 chore: remove console.log for session in AddGithubProvider component to clean up code 2026-03-07 18:10:35 -06:00
Mauricio Siu
a8467e80e8 refactor: replace authClient with api.user.session.useQuery in multiple components for improved session management 2026-03-07 18:02:25 -06:00
Mauricio Siu
95e14b4199 Merge pull request #3930 from Dokploy/3924-docker-composeyml-excessive-alias-count-indicates-a-resource-exhaustion-attack
feat: add maxAliasCount option to parse function for improved Docker …
2026-03-07 17:44:35 -06:00
Mauricio Siu
076262e479 feat: add maxAliasCount option to parse function for improved Docker Compose file handling 2026-03-07 17:44:01 -06:00
Mauricio Siu
c4f4db3ebc Merge pull request #3921 from Dokploy/3789-mongodb-restore-failed-with-gzip-backupsqlgz-no-such-file-or-directory-error
feat: include backup file in restoreComposeBackup function for improv…
2026-03-07 02:38:54 -06:00
Mauricio Siu
4882bd25ad feat: include backup file in restoreComposeBackup function for improved restore process 2026-03-07 02:38:29 -06:00
Mauricio Siu
7a8f2e53d5 Merge pull request #3920 from Dokploy/3286-azure-openai-endpoint-not-working
fix: prevent doubled /v1/ suffix in Azure OpenAI-compatible URLs
2026-03-07 02:33:23 -06:00
Mauricio Siu
50182a8048 fix: prevent doubled /v1/ suffix in Azure OpenAI-compatible URLs 2026-03-07 02:32:47 -06:00
Mauricio Siu
35d35028f6 Merge pull request #3919 from Dokploy/3855-instead-of-keeping-x-latest-backups-all-database-dokploy-web-server-backups-are-deleted
refactor: update backup file paths to include app name for better org…
2026-03-07 01:55:40 -06:00
Mauricio Siu
a5a4a1a818 refactor: update backup file paths to include app name for better organization 2026-03-07 01:48:11 -06:00
Mauricio Siu
c106d13ab5 Merge pull request #3918 from Dokploy/2686-volume-backups-delete-other-volume-backups
refactor: enhance volume backup path handling to ensure proper prefix…
2026-03-07 01:23:58 -06:00
Mauricio Siu
808001d8de refactor: enhance volume backup path handling to ensure proper prefix usage 2026-03-07 01:22:53 -06:00
Mauricio Siu
ce24eadbb4 Merge pull request #3917 from Dokploy/3752-an-error-have-occured-deployment-not-found
refactor: streamline deployment cleanup by consolidating removeLastTe…
2026-03-07 00:53:28 -06:00
Mauricio Siu
b87f8cc5d8 refactor: streamline deployment cleanup by consolidating removeLastTenDeployments calls 2026-03-07 00:51:28 -06:00
Mauricio Siu
f650200771 Merge pull request #3915 from Dokploy/3775-volume-backup-marked-as-failed-due-to-email-error-450-the-html-field-contains-invalid-input
fix: add error handling for volume backup notification sending
2026-03-07 00:41:54 -06:00
autofix-ci[bot]
f961dc6e7a [autofix.ci] apply automated fixes 2026-03-07 06:41:44 +00:00
Mauricio Siu
4be25da185 fix: add error handling for volume backup notification sending 2026-03-07 00:41:14 -06:00
Mauricio Siu
675c1d7a7d Merge pull request #3914 from Dokploy/3900-local-domains-fetch-failure-for-git-providers-when-using-local-lan-domains
refactor: update Gitea and GitLab URL handling to prioritize internal…
2026-03-07 00:34:37 -06:00
autofix-ci[bot]
28cc361c47 [autofix.ci] apply automated fixes 2026-03-07 06:34:27 +00:00
Mauricio Siu
cedec5239f refactor: update Gitea and GitLab URL handling to prioritize internal URLs if available 2026-03-07 00:33:54 -06:00
Mauricio Siu
2f4cbbd3ac Merge pull request #3913 from Dokploy/3905-isolated-deployment-swarm---network-error
fix: update Docker network creation command to specify driver for sta…
2026-03-07 00:22:40 -06:00
Mauricio Siu
38b20450dc fix: update Docker network creation command to specify driver for stack deployments 2026-03-07 00:21:29 -06:00
Mauricio Siu
49f43ab3fb Merge pull request #3912 from Dokploy/3820-compose-file-editor-cmdf-search-no-longer-works-regression
Update dependencies in pnpm-lock.yaml and package.json for @codemirro…
2026-03-06 23:20:20 -06:00
Mauricio Siu
2eae756cec Update dependencies in pnpm-lock.yaml and package.json for @codemirror packages
- Added @codemirror/search version 6.6.0.
- Updated @codemirror/view to version 6.39.15 across multiple files.
- Adjusted imports in code-editor.tsx to include search functionality.

This update ensures compatibility with the latest features and improvements in the CodeMirror library.
2026-03-06 23:18:29 -06:00
Mauricio Siu
70c261d021 Update packages/server/src/constants/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-06 11:43:57 -06:00
Mauricio Siu
9ae2ebff46 Bump version from v0.28.3 to v0.28.4 2026-03-06 08:27:12 -06:00
Mauricio Siu
8ce880d108 Merge pull request #3899 from Dokploy/3819-preview-deployments-incorrectly-inherit-www-redirect
fix: skip redirect middleware for preview deployments to prevent wild…
2026-03-05 11:12:12 -06:00
Mauricio Siu
34304526b1 fix: skip redirect middleware for preview deployments to prevent wildcard subdomain inheritance 2026-03-05 11:08:31 -06:00
Mauricio Siu
a16c4c1294 Merge pull request #3898 from Dokploy/3850-zod-validation-on-undefined-default-values
feat: add enableSubmodules and update watchPaths in application schema
2026-03-05 10:49:47 -06:00
Mauricio Siu
d1c4ac20e3 feat: add enableSubmodules and update watchPaths in application schema 2026-03-05 10:48:47 -06:00
Mauricio Siu
0195119a86 Merge pull request #3894 from Dokploy/3888-deploy-error-client-version-153-is-too-new-on-synology-920
feat: enhance Docker configuration with environment variables for API…
2026-03-05 00:47:46 -06:00
Mauricio Siu
48a577e792 feat: enhance Docker configuration with environment variables for API version, host, and port 2026-03-05 00:46:13 -06:00
Mauricio Siu
bf7a75dd9f Merge pull request #3882 from aak-lear/fix/rollback-registry-auth
fix: add docker login before rollback and fix execAsyncRemote argument order
2026-03-04 22:11:04 -06:00
Mauricio Siu
d316aa4401 Merge pull request #3893 from Dokploy/3853-web-server-backup-fails-when-unreadable-files-unix-sockets-named-pipes-exist-under-etcdokploy
fix: update rsync command in web-server backup to exclude special fil…
2026-03-04 21:37:15 -06:00
Mauricio Siu
f1b2cc35b3 fix: update rsync command in web-server backup to exclude special files and devices 2026-03-04 21:21:46 -06:00
lear
d2fabc998d refactor: reuse safeDockerLoginCommand from registry.ts instead of duplicating shEscape 2026-03-04 12:45:57 +03:00
lear
7185047eb7 fix: add docker login before rollback and fix execAsyncRemote argument order 2026-03-04 11:07:42 +03:00
Mauricio Siu
7121fbe50a Merge pull request #3881 from Dokploy/3864-file-mount-content-not-updated-on-host-when-edited-in-advanced-tab-ui-wordpress-service
refactor: simplify createMount mutation by returning the promise dire…
2026-03-03 22:59:32 -06:00
Mauricio Siu
36cf3a69fc refactor: simplify createMount mutation by returning the promise directly
Updated the createMount mutation to return the promise from createMount directly, enhancing readability. Additionally, adjusted the serviceType schema definition for clarity by removing the default value assignment.
2026-03-03 22:55:46 -06:00
Mauricio Siu
c34a01a173 Merge pull request #3880 from Dokploy/3876-auth-session-ui-not-updating-after-profile-picture-change
refactor: replace authClient with api.organization.active for active …
2026-03-03 22:39:04 -06:00
Mauricio Siu
9ac147a140 refactor: replace authClient with api.organization.active for active organization queries
Updated components to use the new API method for fetching the active organization, improving consistency across the codebase. This change enhances maintainability and aligns with recent API updates.
2026-03-03 22:37:42 -06:00
Mauricio Siu
20f79ac655 fix: update import statements to include file extensions for consistency 2026-03-03 15:35:37 -06:00
Mauricio Siu
6f21f1cc1f Merge pull request #3868 from Dokploy/feat/show-org-deployment-level
Feat/show org deployment level
2026-03-03 14:12:21 -06:00
Mauricio Siu
af76548482 refactor: streamline event fetching and improve UI table layout
Updated the event fetching logic to utilize Promise.all for concurrent API calls, enhancing performance. Adjusted the UI table layout by modifying the column span for better alignment and presentation of the empty queue state. Introduced constants for maximum events to improve code clarity.
2026-03-03 14:09:46 -06:00
Mauricio Siu
47ab89c5be Merge branch 'canary' into feat/add-whitelabelling 2026-03-03 12:46:13 -06:00
Mauricio Siu
13638d0f04 chore: bump version to v0.28.3 in package.json 2026-03-03 12:07:05 -06:00
Mauricio Siu
edceebec7e feat: update .env.example with Inngest configuration examples
Added self-hosted and production configuration examples for Inngest to the .env.example file. This enhancement provides clearer guidance for developers on setting up the Inngest integration.
2026-03-03 01:05:37 -06:00
autofix-ci[bot]
7599565e73 [autofix.ci] apply automated fixes 2026-03-03 07:05:09 +00:00
Mauricio Siu
08c9113405 feat: implement deployment jobs API and enhance queue management
Added a new endpoint to fetch deployment jobs for a server, integrating with the Inngest API to retrieve job details. Updated the queue management system to support centralized job retrieval for cloud environments, improving the deployment monitoring experience. Enhanced the UI to include action buttons for job cancellation and improved error handling for job fetching.
2026-03-03 01:04:26 -06:00
Mauricio Siu
1014d4674c feat: add deployments dashboard with tables for deployments and queue
Introduced a new deployments page that includes a table for viewing all application and compose deployments, as well as a queue table for monitoring deployment jobs. Updated the sidebar to include a link to the new deployments section. Enhanced the API to support centralized deployment queries and job queue retrieval, improving overall deployment management and visibility.
2026-03-02 00:06:27 -06:00
Mauricio Siu
39b40c58bb Merge pull request #3838 from lklacar/fix/service-card-behavior
fix: Fixed service card behavior #3837
2026-03-01 15:38:51 -06:00
Mauricio Siu
1861e10b2a Merge pull request #3859 from Dokploy/3851-ui-3-issues-in-1
feat: enhance request logging display with formatted status and duration
2026-03-01 15:23:49 -06:00
Mauricio Siu
964e3c4150 feat: enhance request logging display with formatted status and duration
Added helper functions to format status labels and execution durations in the requests dashboard. Updated the display logic to show "N/A" for zero status and improved duration representation in microseconds and milliseconds. This enhances the clarity and usability of the request logs for better monitoring and analysis.
2026-03-01 15:11:41 -06:00
Mauricio Siu
e05f31d8c6 Merge pull request #3857 from Dokploy/feat/improve-queries
feat: enhance project and environment services with additional column…
2026-03-01 14:24:16 -06:00
Mauricio Siu
cc3b902d1e feat: include project name in API response columns
Added the 'name' column to the project API response structure to enhance the data returned for project queries. This change improves the clarity and usability of the API by ensuring that project names are included in the response, facilitating better data handling for clients.
2026-03-01 14:20:08 -06:00
Mauricio Siu
6c1f2372ed refactor: clean up project dashboard and API response structure
Removed unused imports and redundant code in the project dashboard component to enhance readability. Updated the API project router to streamline the data structure by eliminating unnecessary domain retrievals, while ensuring essential application and compose details are still included. This refactor improves maintainability and optimizes data handling for the project management interface.
2026-03-01 14:15:47 -06:00
Mauricio Siu
7da69862e1 refactor: update project query to use permissions-aware endpoint
Replaced the existing project query with the new `allForPermissions` endpoint to enhance data retrieval for server monitoring settings. This change aligns with recent API enhancements aimed at improving permissions management.
2026-03-01 14:07:16 -06:00
autofix-ci[bot]
612e73bb80 [autofix.ci] apply automated fixes 2026-03-01 20:02:48 +00:00
Mauricio Siu
a360a259f5 feat: add admin-only endpoint for project permissions with detailed environment data
Introduced a new API endpoint `allForPermissions` to retrieve projects along with their environments and services specifically for admin users. This enhancement allows for a more comprehensive permissions UI by including detailed information about each environment and its associated applications, improving the overall user experience in managing permissions.
2026-03-01 14:02:00 -06:00
Mauricio Siu
149293f4d3 feat: enhance mysql configuration with specific column selections
Updated the mysql configuration in the environment service to include specific column selections for the server object. This change improves data structure clarity and allows for more precise data handling in future queries.
2026-03-01 13:57:17 -06:00
Mauricio Siu
a8a5e1c6f1 refactor: remove unused environment property in duplicateEnvironment function
Eliminated the 'env' property from the duplicateEnvironment function to streamline the code and improve clarity. This change enhances maintainability by removing unnecessary parameters.
2026-03-01 13:47:38 -06:00
Mauricio Siu
4ede21eda9 feat: enhance project and environment services with additional column selections
Updated project and environment services to include specific column selections for various database entities. This improves data retrieval efficiency and allows for more granular control over the returned data structure. Added columns for application, mariadb, mongo, mysql, postgres, redis, and compose entities, as well as enhancements to the environment query structure.
2026-03-01 13:42:34 -06:00
Mauricio Siu
e275e9162e Merge pull request #3846 from Dokploy/feat/add-more-endpoints-for-search
feat: add search functionality across multiple routers with member ac…
2026-03-01 01:27:38 -06:00
autofix-ci[bot]
60a6dc5fab [autofix.ci] apply automated fixes 2026-03-01 07:15:20 +00:00
Mauricio Siu
705c5bc1c9 feat: add search functionality across multiple routers with member access control
Implemented a search feature in application, compose, environment, mariadb, mongo, mysql, postgres, project, and redis routers. Each search allows filtering by various parameters and respects user permissions based on their role. The search queries utilize optimized conditions for efficient data retrieval.
2026-03-01 01:14:46 -06:00
Mauricio Siu
8d56544c1d Merge pull request #3844 from Dokploy/fix/improve-loading-queries
Fix/improve loading queries
2026-02-28 23:06:59 -06:00
Mauricio Siu
ca527ab6ff test: add mock implementation for member.findMany in application command and real tests 2026-02-28 22:59:24 -06:00
Mauricio Siu
439fa17292 chore: bump version to v0.28.2 in package.json 2026-02-28 22:52:04 -06:00
Mauricio Siu
096c04486c refactor: increase cache TTL for trusted origins in admin service 2026-02-28 22:42:15 -06:00
Mauricio Siu
c9e1079076 refactor: remove console log from BreadcrumbSidebar component 2026-02-28 22:39:20 -06:00
Mauricio Siu
e29a86a85f refactor: optimize trusted origins retrieval and caching in auth and admin services 2026-02-28 22:33:31 -06:00
Luka Klacar
f9dedd979e fix: Fixed service card behavior #3837 2026-02-28 23:12:31 +01:00
Mauricio Siu
1ba0eb0c2e Merge pull request #3835 from Dokploy/3799-bug-misleading-error---compose-file-not-found-when-no-domains-configured
refactor: simplify domain handling in Docker compose utility functions
2026-02-28 11:27:34 -06:00
Mauricio Siu
d7dc10993e refactor: simplify domain handling in Docker compose utility functions 2026-02-28 11:23:16 -06:00
Mauricio Siu
2a5d3975e8 chore: remove proprietary README.md and add database connection logic in index.ts 2026-02-28 11:14:12 -06:00
Mauricio Siu
9f3356ddb4 Merge pull request #3834 from Dokploy/3833-error-when-connecting-a-git-github-app---internal-server-error
fix: handle optional chaining for organization and user IDs in GitHub…
2026-02-28 11:07:27 -06:00
Mauricio Siu
f5674f5bf8 fix: handle optional chaining for organization and user IDs in GitHub provider setup 2026-02-28 11:06:45 -06:00
Mauricio Siu
17a617e585 refactor: standardize dialog component formatting in MariaDB, MySQL, and Redis update files 2026-02-27 14:01:44 -06:00
Mauricio Siu
f50eea9e05 Merge pull request #3826 from Dokploy/3822-renaming-redis-doesnt-close-the-update-dialog
feat: add state management for dialog visibility in MariaDB, MySQL, a…
2026-02-27 14:01:02 -06:00
Mauricio Siu
81ee8f653a feat: add state management for dialog visibility in MariaDB, MySQL, and Redis update components 2026-02-27 14:00:35 -06:00
Mauricio Siu
9507745cc0 Merge pull request #3824 from Dokploy/fix/breaking-change-api-rever4t
chore: update @dokploy/trpc-openapi to version 0.0.17 and adjust Open…
2026-02-27 13:55:48 -06:00
Mauricio Siu
d33e164876 chore: bump version to v0.28.1 in package.json 2026-02-27 13:55:18 -06:00
Mauricio Siu
7e6e815375 chore: update @dokploy/trpc-openapi to version 0.0.17 and adjust OpenAPI document URL in settings router 2026-02-27 13:54:58 -06:00
Mauricio Siu
8ab139e222 Merge pull request #3817 from Dokploy/refactor/support-only-for-startup-plan
Refactor/support only for startup plan
2026-02-27 00:20:54 -06:00
Mauricio Siu
55c04b1323 feat: add current billing plan retrieval and enhance SSH error messages with user-friendly guidance 2026-02-27 00:20:20 -06:00
Mauricio Siu
29fc8bfa97 fix: update documentation links for server setup and SSH key creation components 2026-02-27 00:11:00 -06:00
Mauricio Siu
e90d8c0ac4 Merge pull request #3777 from karyanayandi/fix/stable-composite-keys-in-mapped-lists
fix: replace index-only keys with stable composite keys in mapped lists
2026-02-26 22:53:22 -06:00
Mauricio Siu
98d09f187e Merge pull request #3816 from Dokploy/3815-in-database-backups-of-docker-compose-type-projects-the-keep-the-latest-n-function-does-not-take-effect
feat: add functionality to keep the latest N backups after running a job
2026-02-26 22:27:04 -06:00
Mauricio Siu
f96a6a509b feat: add functionality to keep the latest N backups after running a job 2026-02-26 22:26:30 -06:00
Mauricio Siu
df27b8b748 Merge pull request #3813 from Dokploy/3463-container-metrics-query-broken-after-like-change-in-3438
refactor: streamline container name processing in metric retrieval fu…
2026-02-26 21:42:40 -06:00
Mauricio Siu
5c915bc1b9 refactor: streamline container name processing in metric retrieval functions 2026-02-26 21:39:57 -06:00
Mauricio Siu
a188ed3914 Merge pull request #3812 from Dokploy/3354-requests-tab-doesnt-show-the-requests-made-to-a-newly-added-projectservice
refactor: remove unnecessary access log filters from settings configu…
2026-02-26 21:23:28 -06:00
Mauricio Siu
0094b67c13 refactor: remove unnecessary access log filters from settings configuration 2026-02-26 21:22:41 -06:00
Mauricio Siu
0b9dc3a1a2 Merge pull request #3811 from Dokploy/3332-zombie-processes-from-failed-log-cleanup
refactor: improve error handling and logging in cron job setup for ba…
2026-02-26 21:05:30 -06:00
Mauricio Siu
f8628269b9 refactor: improve error handling and logging in cron job setup for backups and log cleanup 2026-02-26 21:04:16 -06:00
Mauricio Siu
345023f090 docs: update contribution guidelines to clarify testing requirements and large feature submissions 2026-02-25 23:46:14 -06:00
Mauricio Siu
3352f7a1c9 Merge pull request #3776 from karyanayandi/fix/replace-full-lodash-imports
fix: replace full lodash imports with per-function imports
2026-02-25 23:41:16 -06:00
Mauricio Siu
9e1406e6c8 chore: update dokploy version to v0.28.0 in package.json 2026-02-25 23:40:59 -06:00
Mauricio Siu
798bfc2b92 Merge pull request #3801 from HPaulson/patch-1
style: Minor Refactor to VPS recommendation message
2026-02-25 23:39:36 -06:00
Mauricio Siu
9d07903e71 Merge pull request #3804 from Dokploy/3749-updating-the-basic-auth-account-password-did-not-update-the-traefik-middleware-configuration
feat: enhance security handling by adding refetch capability and impr…
2026-02-25 23:31:51 -06:00
Mauricio Siu
4baf77c740 feat: enhance security handling by adding refetch capability and improving error messages in security service 2026-02-25 23:30:28 -06:00
Mauricio Siu
0adfa51174 Merge pull request #3802 from Dokploy/3768-volume-backup-custom-webhook-notifications-not-working
feat: add custom notification support for volume backup status updates
2026-02-25 23:05:06 -06:00
Mauricio Siu
7a25a9f5b4 feat: add custom notification support for volume backup status updates 2026-02-25 23:04:13 -06:00
Hunter Paulson
3d81b98f48 style: Refactor VPS recommendation message for clarity 2026-02-25 23:47:06 -05:00
Mauricio Siu
9b4a37eebf Merge pull request #3800 from Dokploy/3765-missing-mounts-endpoint-for-managed-database-services
feat: enhance mount API to support service type filtering and organiz…
2026-02-25 22:09:00 -06:00
Mauricio Siu
a467920410 feat: enhance mount API to support service type filtering and organization access checks 2026-02-25 22:08:05 -06:00
Mauricio Siu
e62bb7593a fix: add early return for empty patches in generateApplyPatchesCommand to prevent unnecessary command execution 2026-02-25 22:00:40 -06:00
Mauricio Siu
4af0bdc27b Merge pull request #3796 from Dokploy/feat/add-versioning-cloud-images
chore: enhance Docker image tagging in GitHub Actions workflow to inc…
2026-02-25 01:36:55 -06:00
Mauricio Siu
b9da9e6367 chore: enhance Docker image tagging in GitHub Actions workflow to include versioning based on branch context 2026-02-25 01:36:24 -06:00
Mauricio Siu
1ea1f6b603 chore: update @dokploy/trpc-openapi to version 0.0.16 in package.json and pnpm-lock.yaml for improved dependency management 2026-02-25 01:26:12 -06:00
Mauricio Siu
677fedbbc0 Merge pull request #3783 from Dokploy/feat/add-support-for-trusted-providers
chore: update @better-auth/sso and better-auth to version 1.5.0-beta.…
2026-02-25 01:01:22 -06:00
Mauricio Siu
234862e5b6 chore: update build scripts to use rimraf for cleaning dist directory and add rimraf as a dependency in relevant package.json files 2026-02-24 23:17:14 -06:00
Mauricio Siu
b8bfee7f87 chore: add path mapping for server module in tsconfig.json to improve module resolution in the schedules app 2026-02-24 22:55:42 -06:00
Mauricio Siu
2a3ae89f5e refactor: replace global db declaration with a globalForDb object to prevent redeclaration issues in monorepos and improve connection management in development and production environments 2026-02-24 22:48:29 -06:00
Mauricio Siu
c22e006e30 refactor: rename db variable to dbConnection for improved clarity and export the connection for consistent usage across the application 2026-02-24 22:44:48 -06:00
Mauricio Siu
66ef8a0a5f refactor: update database type declaration to use a centralized Database type for improved clarity and consistency 2026-02-24 22:41:54 -06:00
Mauricio Siu
d29de8fcba chore: remove DEBUG-BUILD.md file as it is no longer needed for debugging instructions 2026-02-24 20:32:50 -06:00
Mauricio Siu
42eeade121 chore: update drizzle-orm version to 0.45.1 across all package.json files and remove catalog reference for improved dependency management 2026-02-24 19:48:42 -06:00
autofix-ci[bot]
8e893e6c64 [autofix.ci] apply automated fixes 2026-02-25 01:41:27 +00:00
Mauricio Siu
e0c5273eb3 chore: centralize drizzle-orm versioning by using a catalog reference in package.json files and update pnpm-workspace.yaml for shared dependency management 2026-02-24 19:41:04 -06:00
Mauricio Siu
a1ebb804fe refactor: reorganize imports in utils.ts to enhance clarity and maintainability 2026-02-24 19:01:44 -06:00
Mauricio Siu
b2218efce6 refactor: update database imports to use centralized db module for improved consistency across API routes and schemas 2026-02-24 19:00:30 -06:00
Mauricio Siu
b027d21589 refactor: simplify API configuration by returning links directly and moving transformer to the main return object for improved clarity 2026-02-24 18:38:26 -06:00
Mauricio Siu
2d0874d499 feat: add triggerType field to application schema for enhanced event handling 2026-02-24 18:35:26 -06:00
Mauricio Siu
e92ba584c0 refactor: utilize createInsertSchema for server API schemas to enhance consistency and clarity across create, update, and remove operations 2026-02-24 13:04:51 -06:00
Mauricio Siu
53b612534d refactor: update volume backup and environment description handling to use undefined instead of null for improved consistency; enhance error handling in AI component 2026-02-24 13:03:42 -06:00
Mauricio Siu
7b6ece0b65 refactor: streamline mount schema definitions by utilizing createSchema for improved consistency and clarity 2026-02-24 13:00:40 -06:00
Mauricio Siu
3b7dcaca7a chore: update drizzle-orm version to 0.41.0 in package.json and pnpm-lock.yaml; refactor schema definitions for improved clarity and consistency across various database schemas 2026-02-24 12:58:22 -06:00
autofix-ci[bot]
0d0383f84a [autofix.ci] apply automated fixes 2026-02-24 07:07:46 +00:00
Mauricio Siu
331b12c7d8 chore: update @dokploy/trpc-openapi to version 0.0.14 and swagger-ui-react to version 5.31.2; refactor OpenAPI document handling in swagger.tsx and settings.ts for improved compatibility and clarity 2026-02-24 01:07:23 -06:00
Mauricio Siu
7c534d62b6 chore: update dependencies in pnpm-lock.yaml and package.json, including zod to version 4.3.6, @dokploy/trpc-openapi to version 0.0.13, and @trpc packages to version 11.10.0; refactor loading state handling in application components 2026-02-23 23:50:55 -06:00
autofix-ci[bot]
df648eccf6 [autofix.ci] apply automated fixes 2026-02-23 17:11:01 +00:00
Mauricio Siu
fca10c135a chore: update zod and react-hook-form integration to use standard-schema resolver; update dependencies in pnpm-lock.yaml 2026-02-23 11:10:28 -06:00
Mauricio Siu
03969b8f45 refactor: restructure auth export to improve type safety and clarity 2026-02-23 02:43:31 -06:00
Mauricio Siu
d8e08558cc chore: update dependencies in pnpm-lock.yaml to latest versions, including hono, ai-sdk packages, and react-hook-form 2026-02-23 02:13:51 -06:00
Mauricio Siu
293ad3862a chore: update @better-auth/utils to version 0.3.1 in package.json and pnpm-lock.yaml 2026-02-23 02:06:36 -06:00
autofix-ci[bot]
6cc646c974 [autofix.ci] apply automated fixes 2026-02-23 08:00:25 +00:00
Mauricio Siu
d00ec952a9 fix: remove unnecessary console log for provider in auth.ts 2026-02-23 02:00:04 -06:00
Mauricio Siu
74461c860e chore: update @better-auth/sso and better-auth to version 1.5.0-beta.16; rename session_temp table to session and adjust related constraints; add new SQL migration for session table; implement getTrustedProviders function for dynamic provider retrieval 2026-02-23 01:58:59 -06:00
Mauricio Siu
9d09e51cf7 Merge pull request #3780 from Dokploy/3673-dokploy-fails-with-bad-request-after-webui-triggered-update-and-does-not-recover-automatically
fix: add error handling to dokploy restart notifications
2026-02-22 20:06:53 -06:00
Mauricio Siu
e3aadf1908 fix: add error handling to dokploy restart notifications 2026-02-22 20:05:58 -06:00
Yandi
69d3286aaf fix: replace index-only keys with stable composite keys in mapped lists 2026-02-22 20:34:38 +07:00
Yandi
0b1c1e8b8c fix: replace full lodash imports with per-function imports
Importing the entire lodash library adds ~70kb to the bundle. Switch to
specific imports (lodash/debounce, lodash/escapeRegExp) so only the used
functions are included.
2026-02-22 20:34:38 +07:00
Mauricio Siu
5435e1dac4 Merge pull request #3760 from jirkavrba/feat/optional-dockerfile-path
feat: Make dockerfile path optional and default to "Dockerfile"
2026-02-22 01:48:17 -06:00
Jiří Vrba
999e5fabd3 feat: Add Dockerfile as default value for the application database column 2026-02-21 11:34:40 +01:00
Jiří Vrba
6a2098d522 Remove useless defaultValue 2026-02-20 09:31:34 +01:00
Jiří Vrba
5d3e05536e feat: Make dockerfile path optional and default to "Dockerfile" 2026-02-20 09:19:20 +01:00
Mauricio Siu
3e461f642e Merge pull request #3750 from mixelburg/fix/monitoring-memory-leak
fix: cap monitoring chart data to prevent memory leak
2026-02-19 15:06:35 -06:00
Mauricio Siu
7a62f47e43 Merge pull request #3758 from Dokploy/feat/add-new-pricing
Feat/add new pricing
2026-02-19 15:01:18 -06:00
Mauricio Siu
daf700429d refactor: update billing pricing calculations and display
- Revised pricing calculations for Hobby and Startup tiers to reflect accurate annual rates with 20% discount.
- Updated comments for clarity and consistency in pricing logic.
- Adjusted billing display to show the correct annual pricing for additional servers.
2026-02-19 14:57:45 -06:00
Mauricio Siu
781bf5e116 refactor: remove commented-out code and clean up Stripe router
- Eliminated outdated comments related to legacy plan detection and current pricing calculations.
- Improved code readability by removing unnecessary comments that no longer serve a purpose.
2026-02-19 14:53:28 -06:00
autofix-ci[bot]
66190434a7 [autofix.ci] apply automated fixes 2026-02-19 20:51:00 +00:00
Mauricio Siu
2b42ef7829 refactor: simplify product ID handling and refine validation logic in Stripe router
- Updated the product ID array construction to directly include Hobby and Startup product IDs.
- Streamlined the refinement logic for server quantity validation, improving readability without altering functionality.
- Ensured consistent validation messages for the Startup plan requirements.
2026-02-19 14:50:28 -06:00
Mauricio Siu
97374f736e feat: update billing display to show monthly pricing for additional servers
- Modified the billing section to clarify that users can add more servers at the specified monthly rate.
- Added monthly pricing display for both Hobby and Startup tiers when the annual plan is selected.
- Enhanced user experience by providing clearer pricing information based on server quantity.
2026-02-19 12:45:18 -06:00
Mauricio Siu
28fc58d898 feat: enhance billing upgrade process with confirmation dialog and toast notifications
- Integrated a confirmation dialog for upgrading plans, providing users with clear details about their current and new plans.
- Added toast notifications to inform users of successful upgrades or errors during the process.
- Updated UI elements for better styling and user experience in the billing section.
2026-02-19 12:11:27 -06:00
Mauricio Siu
32a14be564 feat: enhance billing functionality with new pricing tiers and upgrade options
- Added new pricing calculations for Hobby and Startup tiers, including server quantity handling.
- Implemented upgrade functionality for users on legacy plans to switch to Hobby or Startup plans.
- Updated Stripe webhook to correctly calculate server quantities based on subscription items.
- Refactored getStripeItems utility to accommodate new billing tiers and server quantity logic.
2026-02-19 02:04:59 -06:00
Maks Pikov
e874b2c459 fix: cap monitoring chart data to prevent memory leak (#3712) 2026-02-19 07:42:08 +00:00
Mauricio Siu
660bc3cd00 Merge pull request #3591 from vtomasr5/allow-zipdrop-build-server
fix: extract ZIP drop deployment to build server when configured
2026-02-18 20:20:27 -06:00
Mauricio Siu
c98548fa51 Merge pull request #3574 from ivanpadavan/patches-impl
feat: Add patches functionality
2026-02-18 20:19:06 -06:00
Mauricio Siu
0d4d60953e test: add mock for patch service in application.command.test.ts and application.real.test.ts
- Introduced a mock for the patch service's findMany method in both test files to simulate database behavior.
- This change enhances test coverage and ensures consistency across the test suite.
2026-02-18 15:35:14 -06:00
Mauricio Siu
f7079f51de test: remove unused mock for patch service in application.command.test.ts
- Removed the mock for the patch service to streamline the test setup.
- This change enhances test clarity by eliminating unnecessary mock definitions.
2026-02-18 15:08:14 -06:00
Mauricio Siu
15a1a5d0aa test: remove unused mock for patch service in application.real.test.ts
- Removed the mock for the patch service to streamline the test setup.
- This change enhances test clarity by eliminating unnecessary mock definitions.
2026-02-18 15:03:09 -06:00
Mauricio Siu
d99e0bf4dd test: remove unused mock constants in application.real.test.ts
- Eliminated the mock constants for paths to simplify the test setup.
- This change enhances test clarity by focusing on relevant mocks and reducing unnecessary complexity.
2026-02-18 15:02:44 -06:00
Mauricio Siu
20acc8bce5 test: remove unused patch mock and deployment test in application.real.test.ts
- Eliminated the mock for the patch service and the associated deployment test case to streamline the test suite.
- This change focuses on improving test clarity and reducing complexity by removing redundant code.
2026-02-18 15:01:50 -06:00
Mauricio Siu
5ef431b9e9 test: update mock implementation in application.real.test.ts
- Replaced the mock for the patch service with a mock for db.query.patch.findMany to better simulate database behavior.
- Added type and updatedAt fields to the mock patch object for improved test accuracy.
2026-02-18 14:54:24 -06:00
Mauricio Siu
3439b758df test: add mock for patch table in application.real.test.ts
- Introduced a mock for the patch table to simulate findMany behavior in application.real.test.ts, enhancing test coverage.
2026-02-18 14:54:17 -06:00
Mauricio Siu
97f9e8ad25 test: enhance application tests with patch mock and additional paths
- Added a mock for the patch table in application.command.test.ts to simulate findMany behavior.
- Updated application.real.test.ts to include new paths for COMPOSE_PATH, SSH_PATH, and BASE_PATH for improved test coverage.
2026-02-18 14:51:28 -06:00
Mauricio Siu
5faa319b69 feat: add patch table and related constraints
- Introduced a new SQL file to create a "patch" table with a custom ENUM type "patchType" for tracking changes.
- Added foreign key constraints linking "applicationId" and "composeId" to their respective tables.
- Removed the previous SQL file that contained redundant definitions for the "patchType" and its column in the "patch" table.
2026-02-18 14:40:48 -06:00
Mauricio Siu
42e8320866 Merge branch 'canary' into patches-impl 2026-02-18 14:39:54 -06:00
Mauricio Siu
b0c6b1338d chore: update deployment command in Dockerfiles to include --legacy flag
- Modified deployment commands in Dockerfile, Dockerfile.cloud, Dockerfile.schedule, and Dockerfile.server to add the --legacy flag for compatibility during deployment.
2026-02-18 12:11:02 -06:00
Mauricio Siu
309a411718 delete: remove integration tests for patch generation and application 2026-02-18 11:55:55 -06:00
Mauricio Siu
ef65e0934e Merge branch 'canary' into patches-impl 2026-02-18 11:54:53 -06:00
Mauricio Siu
84dd6458aa Merge pull request #3743 from Dokploy/feat/migrate-to-node-24
chore: upgrade Node.js version and related dependencies to 24.4.0
2026-02-18 11:54:13 -06:00
Mauricio Siu
37e5c52cbe chore: update fast-xml-parser and strnum dependencies
- Removed fast-xml-parser version 4.3.4 and updated to version 5.3.6 in package.json and pnpm-lock.yaml.
- Updated strnum dependency from version 1.1.2 to 2.1.2 in pnpm-lock.yaml.
2026-02-18 11:53:10 -06:00
autofix-ci[bot]
49914c5d92 [autofix.ci] apply automated fixes 2026-02-18 17:48:12 +00:00
Mauricio Siu
9df4398c8f chore: upgrade pnpm version to 10.22.0 across all Dockerfiles and package.json files
- Updated pnpm version from 9.12.0 to 10.22.0 in Dockerfiles for base, cloud, schedule, and server.
- Adjusted package.json files in multiple applications to reflect the new pnpm version.
- Updated dependencies for better-auth and related packages to version 1.4.18.
2026-02-18 11:47:41 -06:00
Mauricio Siu
a83a742bf3 fix(auth): ensure error message is properly converted to string in API key validation 2026-02-18 11:23:31 -06:00
Mauricio Siu
713aa5fd58 chore: update Node.js version in documentation and workflows to 24.4.0
- Updated the recommended Node.js version in CONTRIBUTING.md to 24.4.0.
- Modified the Node.js version in the sync-openapi-docs.yml workflow to 24.4.0.
2026-02-18 11:19:04 -06:00
Mauricio Siu
b210c48eaa chore: update package versions for better-auth and related dependencies
- Updated @better-auth/sso and better-auth dependencies to version 1.5.0-beta.16 in package.json files across multiple applications.
- Incremented @better-auth/utils version to 0.3.1.
- Adjusted pnpm-lock.yaml to reflect the updated dependency versions.
2026-02-18 11:14:58 -06:00
Mauricio Siu
d70c865dc7 chore: upgrade Node.js version and related dependencies to 24.4.0
- Updated .nvmrc and Dockerfiles to use Node.js 24.4.0.
- Modified package.json files across multiple applications to reflect the new Node.js version and updated @types/node dependency.
- Updated better-auth and @better-auth/sso dependencies to version v1.5.0-beta.16 in relevant package.json files.
- Removed outdated .nvmrc file from apps/dokploy.
2026-02-18 11:10:51 -06:00
Mauricio Siu
df2221a4bd refactor(patch): enforce minimum length for ID input and streamline repo directory access
- Updated ID input validation to require a minimum length of 1 character.
- Simplified the logic for accessing repository directories by consolidating return statements and removing unnecessary error handling for missing application or compose types.
2026-02-18 11:06:57 -06:00
Mauricio Siu
831584550b Merge branch 'canary' into patches-impl 2026-02-18 02:09:57 -06:00
Mauricio Siu
6350a8ddd3 refactor(compose): streamline command generation in deployCompose function
- Removed redundant command initialization and ensured proper command structure for deployment.
- Enhanced clarity by consolidating command building logic, improving maintainability of the deployCompose function.
2026-02-17 11:45:11 -06:00
autofix-ci[bot]
46e1bed5e9 [autofix.ci] apply automated fixes 2026-02-17 08:28:50 +00:00
Mauricio Siu
8aba7b08cf feat(patch): implement CreateFileDialog and integrate file creation in PatchEditor
- Added CreateFileDialog component for creating new files within the dashboard.
- Integrated file creation functionality into the PatchEditor, allowing users to create files directly from the directory structure.
- Enhanced user experience with form validation and success/error notifications during file creation.
- Updated ShowPatches to display file types with badges for better clarity on patch operations.
2026-02-17 02:28:20 -06:00
Mauricio Siu
9eeac50642 feat(patch): add patchType enum and update patch schema
- Introduced a new ENUM type "patchType" with values 'create', 'update', and 'delete' to categorize patch operations.
- Updated the "patch" table schema to include a new "type" column, defaulting to 'update', ensuring better management of patch types.
- Added a new snapshot file for version 7 to reflect the updated database schema.
2026-02-17 02:09:00 -06:00
Mauricio Siu
2db4c448d4 refactor(patch): optimize patch application command generation
- Streamlined the command generation for applying patches by removing unnecessary checks for enabled status within the loop.
- Consolidated patch retrieval and filtering logic to enhance clarity and maintainability.
- Improved directory handling for file paths to ensure proper creation before applying patches, enhancing robustness.
2026-02-17 02:05:54 -06:00
Mauricio Siu
c89f2e302b refactor(patch-editor): remove repoPath dependency and streamline patch handling
- Eliminated the repoPath parameter from the PatchEditor component and related API calls, simplifying the patch management logic.
- Updated the patch retrieval and saving processes to focus on filePath and content, enhancing clarity and maintainability.
- Adjusted the handling of file content in the CodeEditor to ensure it retrieves the correct data, improving user experience.
2026-02-17 01:57:57 -06:00
Mauricio Siu
1c25ab4303 feat(patch-dialog): add EditPatchDialog component for editing patches
- Introduced the EditPatchDialog component to facilitate patch editing within the dashboard.
- Integrated the dialog into the ShowPatches component, allowing users to edit patches directly from the list view.
- Enhanced user experience with loading indicators and success/error notifications during patch updates.
- Updated the UI to ensure consistent styling and behavior across patch management features.
2026-02-17 01:47:27 -06:00
Mauricio Siu
46ac272f3f refactor(patch): unify patch retrieval and application logic
- Consolidated patch retrieval for applications and composes into a single query method, improving code clarity and reducing redundancy.
- Updated the ShowPatches component to utilize the new unified query, simplifying data fetching logic.
- Refactored patch application commands to streamline the process for both application and compose types, enhancing maintainability and consistency across the codebase.
2026-02-17 01:35:08 -06:00
Mauricio Siu
9818e3c3ba refactor(patch): simplify patch repository management and enhance git configuration handling
- Removed redundant helper functions for retrieving git configurations for applications and composes, streamlining the codebase.
- Updated the `ensurePatchRepo` function to directly handle repository cloning based on the application or compose type, improving clarity and maintainability.
- Refactored patch creation logic to eliminate unnecessary checks and streamline the process of creating patches.
- Enhanced the handling of output paths in repository cloning functions across different git providers, ensuring consistent behavior.
2026-02-17 01:11:23 -06:00
Mauricio Siu
20320639ce refactor(patch-editor): streamline patch saving and loading logic
- Removed the local state for saving status and integrated loading state directly from the mutation hook.
- Updated the saveAsPatch function to handle success and error notifications more cleanly.
- Adjusted the file content loading to ensure it retrieves the latest data, improving user experience and consistency in the PatchEditor component.
- Refactored API calls in the patch router to unify patch retrieval logic for applications and composes, enhancing maintainability.
2026-02-17 00:41:46 -06:00
Mauricio Siu
88f387dd83 refactor(patches): unify patch handling for applications and composes
- Updated the PatchEditor and ShowPatches components to accept a unified `id` and `type` prop, replacing the previous separate `applicationId` and `composeId` props.
- Refactored API calls in the patch router to handle both application and compose types, improving code clarity and maintainability.
- Enhanced the UI to provide consistent behavior for creating and managing patches across different types.
2026-02-17 00:24:27 -06:00
autofix-ci[bot]
752f90c330 [autofix.ci] apply automated fixes 2026-02-17 06:04:26 +00:00
Mauricio Siu
0fc043d0ad feat(database): add "patch" table with foreign key constraints and create snapshot for version 7 2026-02-16 23:53:31 -06:00
Mauricio Siu
13b94ed3be Merge branch 'canary' into patches-impl 2026-02-16 23:52:26 -06:00
Mauricio Siu
7747929cdf Remove unused SQL migration and associated journal entries for "0143_cute_forge". 2026-02-16 23:50:46 -06:00
Mauricio Siu
516315db79 feat(whitelabel): implement whitelabeling features and database updates
- Added support for whitelabeling, allowing customization of app name, logo, favicon, tagline, and custom CSS.
- Introduced new database columns to store whitelabel settings.
- Updated various components to utilize whitelabel settings, including the logo and favicon in the UI.
- Enhanced the onboarding layout and sidebar to reflect whitelabel configurations.
- Integrated whitelabel settings into the application head for dynamic title and favicon updates.
2026-02-16 01:59:00 -06:00
user
a4eb0bfea1 migration 2026-02-10 23:45:34 +03:00
user
ce9ba60902 impl 2026-02-10 23:43:34 +03:00
autofix-ci[bot]
5c3b7acd54 [autofix.ci] apply automated fixes 2026-02-04 09:14:32 +00:00
Vicens Juan Tomas Monserrat
44f8590fe8 fix: extract ZIP drop deployment to build server when configured 2026-02-04 10:00:03 +01:00
151 changed files with 6455 additions and 11602 deletions

View File

@@ -1,21 +0,0 @@
# Dockerfile for DevContainer
FROM node:20.16.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -1,53 +0,0 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

View File

@@ -1,22 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

5
.gitignore vendored
View File

@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
# Development environment
.devcontainer

View File

@@ -1,2 +1,11 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""
LEMON_SQUEEZY_STORE_ID=""
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
# INNGEST_BASE_URL="http://localhost:8288"
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
# INNGEST_SIGNING_KEY="your-signing-key"
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
# INNGEST_JOBS_MAX_EVENTS=100

View File

@@ -20,7 +20,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.76"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -10,6 +10,7 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
return c.json({ status: "ok" });
});
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
app.get("/jobs", async (c) => {
const serverId = c.req.query("serverId");
if (!serverId) {
return c.json({ message: "serverId is required" }, 400);
}
try {
const rows = await fetchDeploymentJobs(serverId);
return c.json(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("INNGEST_BASE_URL")) {
return c.json(
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
503,
);
}
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
return c.json([], 200);
}
});
// Serve Inngest functions endpoint
app.on(
["GET", "POST", "PUT"],

239
apps/api/src/service.ts Normal file
View File

@@ -0,0 +1,239 @@
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
const DEFAULT_MAX_EVENTS = 500;
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
type InngestEventRow = {
internal_id?: string;
accountID?: string;
environmentID?: string;
source?: string;
sourceID?: string | null;
/** RFC3339 timestamp API uses receivedAt, dev server may use received_at */
receivedAt?: string;
received_at?: string;
id: string;
name: string;
data: Record<string, unknown>;
user?: unknown;
ts: number;
v?: string | null;
metadata?: {
fetchedAt: string;
cachedUntil: string | null;
};
};
/** Run shape from GET /v1/events/{eventId}/runs the actual job execution */
type InngestRun = {
run_id: string;
event_id: string;
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
run_started_at?: string;
ended_at?: string | null;
output?: unknown;
// dev server / API may use different casing
run_started_at_ms?: number;
};
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
return ev.receivedAt ?? ev.received_at;
}
/** Map Inngest run status to BullMQ-style state for the UI */
function runStatusToState(
status: string,
): "pending" | "active" | "completed" | "failed" | "cancelled" {
const s = status.toLowerCase();
if (s === "running") return "active";
if (s === "completed") return "completed";
if (s === "failed") return "failed";
if (s === "cancelled") return "cancelled";
if (s === "queued") return "pending";
return "pending";
}
export const fetchInngestEvents = async () => {
const maxEvents = MAX_EVENTS;
const all: InngestEventRow[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) {
params.set("cursor", cursor);
}
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
logger.warn("Inngest API error", {
status: res.status,
body: await res.text(),
});
break;
}
const body = (await res.json()) as {
data?: InngestEventRow[];
cursor?: string;
nextCursor?: string;
};
const data = Array.isArray(body.data) ? body.data : [];
all.push(...data);
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
const nextCursor =
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
cursor = hasMore ? nextCursor : undefined;
} while (cursor);
return all.slice(0, maxEvents);
};
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) runs are the actual jobs */
export const fetchInngestRunsForEvent = async (
eventId: string,
): Promise<InngestRun[]> => {
const res = await fetch(
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
{
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
},
);
if (!res.ok) {
logger.warn("Inngest runs API error", {
eventId,
status: res.status,
body: await res.text(),
});
return [];
}
const body = (await res.json()) as { data?: InngestRun[] };
return Array.isArray(body.data) ? body.data : [];
};
/** One row for the queue UI (BullMQ-compatible shape) */
export type DeploymentJobRow = {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
function buildDeploymentRowsFromRuns(
events: InngestEventRow[],
runsByEventId: Map<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
const runs = runsByEventId.get(ev.id) ?? [];
if (runs.length === 0) {
// Queued: event received but no run yet
rows.push({
id: ev.id,
name: ev.name,
data,
timestamp: ev.ts,
processedOn: ev.ts,
finishedOn: undefined,
failedReason: undefined,
state: "pending",
});
continue;
}
for (const run of runs) {
const state = runStatusToState(run.status);
const runStartedMs =
run.run_started_at_ms ??
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
const endedMs = run.ended_at
? new Date(run.ended_at).getTime()
: undefined;
const failedReason =
state === "failed" &&
run.output &&
typeof run.output === "object" &&
"error" in run.output
? String((run.output as { error?: unknown }).error)
: undefined;
rows.push({
id: run.run_id,
name: ev.name,
data,
timestamp: runStartedMs,
processedOn: runStartedMs,
finishedOn:
state === "completed" || state === "failed" || state === "cancelled"
? endedMs
: undefined,
failedReason,
state,
});
}
}
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
}
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
export const fetchDeploymentJobs = async (
serverId: string,
): Promise<DeploymentJobRow[]> => {
if (!signingKey) {
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
return [];
}
if (!baseUrl) {
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
}
const events = await fetchInngestEvents();
const requestedForServer = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};

View File

@@ -9,7 +9,7 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema";
import type { DeployJob } from "./schema.js";
export const deploy = async (job: DeployJob) => {
try {

View File

@@ -83,14 +83,6 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -107,9 +99,6 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,7 +6,6 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -14,10 +13,7 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: "./__test__/drop/zips/output",
}),
};
});
@@ -154,176 +150,6 @@ const baseApp: ApplicationNested = {
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -340,12 +166,14 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});

View File

@@ -1 +0,0 @@
/etc/passwd

View File

@@ -1,81 +0,0 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -105,14 +105,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData =
formData.type === "Replicated"
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
? { Replicated: { Replicas: formData.Replicas } }
: { Global: {} };
await mutateAsync({

View File

@@ -1,4 +1,4 @@
import { Ban } from "lucide-react";
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Ban className="size-4" />
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -1,73 +0,0 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -6,7 +6,6 @@ import {
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -26,7 +25,6 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -79,8 +77,6 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
const {
@@ -148,9 +144,6 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
@@ -259,8 +252,6 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -379,33 +370,6 @@ export const ShowDeployments = ({
View
</Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (

View File

@@ -0,0 +1,613 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
Rocket,
Server,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type DeploymentRow =
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
const statusVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
running: "yellow",
done: "green",
error: "red",
cancelled: "outline",
};
function getServiceInfo(d: DeploymentRow) {
const app = d.application;
const comp = d.compose;
if (app?.environment?.project && app.environment) {
return {
type: "Application" as const,
name: app.name,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
environmentName: app.environment.name,
serviceId: app.applicationId,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
type: "Compose" as const,
name: comp.name,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
environmentName: comp.environment.name,
serviceId: comp.composeId,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
export function ShowDeploymentsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
});
const { data: deploymentsList, isLoading } =
api.deployment.allCentralized.useQuery(undefined, {
refetchInterval: 5000,
});
const filteredData = useMemo(() => {
if (!deploymentsList) return [];
let list = deploymentsList;
if (statusFilter !== "all") {
list = list.filter((d) => d.status === statusFilter);
}
if (typeFilter === "application") {
list = list.filter((d) => d.applicationId != null);
} else if (typeFilter === "compose") {
list = list.filter((d) => d.composeId != null);
}
if (globalFilter.trim()) {
const q = globalFilter.toLowerCase();
list = list.filter((d) => {
const info = getServiceInfo(d);
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
"";
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
if (!info) return false;
return (
info.name.toLowerCase().includes(q) ||
info.projectName.toLowerCase().includes(q) ||
info.environmentName.toLowerCase().includes(q) ||
(d.title?.toLowerCase().includes(q) ?? false) ||
serverName.toLowerCase().includes(q) ||
buildServerName.toLowerCase().includes(q)
);
});
}
return list;
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
const columns = useMemo(
() => [
{
id: "serviceName",
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Service
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
<Badge variant="outline" className="w-fit text-[10px]">
{info.type}
</Badge>
</div>
</div>
);
},
},
{
id: "projectName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.projectName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Project
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.projectName ?? "—"}
</span>
);
},
},
{
id: "environmentName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.environmentName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Environment
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.environmentName ?? "—"}
</span>
);
},
},
{
id: "serverName",
accessorFn: (row: DeploymentRow) =>
row.server?.name ??
row.application?.server?.name ??
row.compose?.server?.name ??
"",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Server
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const d = row.original;
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
null;
const serverType =
d.server?.serverType ??
d.application?.server?.serverType ??
d.compose?.server?.serverType ??
null;
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
const buildServerType =
d.buildServer?.serverType ??
d.application?.buildServer?.serverType ??
null;
const showBuild =
buildServerName != null && buildServerName !== serverName;
if (!serverName && !showBuild) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="flex flex-col gap-0.5 text-sm">
{serverName && (
<div className="flex items-center gap-1.5 flex-wrap">
<Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{serverName}</span>
{serverType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{serverType}
</Badge>
)}
</div>
)}
{showBuild && buildServerName && (
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
<span className="text-[10px]">Build:</span>
<span className="truncate text-xs">{buildServerName}</span>
{buildServerType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{buildServerType}
</Badge>
)}
</div>
)}
</div>
);
},
},
{
accessorKey: "title",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Title
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-sm truncate max-w-[200px] block">
{row.original.title || "—"}
</span>
),
},
{
accessorKey: "status",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Status
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const status = row.original.status ?? "running";
return (
<Badge variant={statusVariants[status] ?? "secondary"}>
{status}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-muted-foreground text-sm whitespace-nowrap">
{row.original.createdAt
? new Date(row.original.createdAt).toLocaleString()
: "—"}
</span>
),
},
{
header: "",
id: "actions",
enableSorting: false,
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return null;
return (
<Button variant="ghost" size="sm" asChild>
<Link href={info.href} className="gap-1">
<ExternalLink className="size-4" />
Open
</Link>
</Button>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type QueueRow =
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
const stateVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
pending: "secondary",
waiting: "secondary",
active: "yellow",
delayed: "outline",
completed: "green",
failed: "destructive",
cancelled: "outline",
paused: "outline",
};
function formatTs(ts?: number): string {
if (ts == null) return "—";
const d = new Date(ts);
return d.toLocaleString();
}
function getJobLabel(row: QueueRow): string {
const d = row.data as {
applicationType?: string;
applicationId?: string;
composeId?: string;
previewDeploymentId?: string;
titleLog?: string;
type?: string;
};
if (!d) return String(row.id);
const type = d.applicationType ?? "job";
const title = d.titleLog ?? "";
if (title) return title;
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}`;
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}`;
if (d.previewDeploymentId)
return `Preview ${d.previewDeploymentId.slice(0, 8)}`;
return `${type} ${String(row.id)}`;
}
export function ShowQueueTable(props: { embedded?: boolean }) {
const { embedded: _embedded = false } = props;
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
undefined,
{ refetchInterval: 3000 },
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const utils = api.useUtils();
const {
mutateAsync: cancelApplicationDeployment,
isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const {
mutateAsync: cancelComposeDeployment,
isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const isCancelling = isCancellingApp || isCancellingCompose;
return (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading queue...</span>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Job ID</TableHead>
<TableHead>Label</TableHead>
<TableHead>Type</TableHead>
<TableHead>State</TableHead>
<TableHead>Added</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queueList?.length ? (
queueList.map((row) => {
const d = row.data as Record<string, unknown>;
const appType = d?.applicationType as string | undefined;
const pathInfo = row.servicePath;
const hasLink = pathInfo?.href != null;
return (
<TableRow key={String(row.id)}>
<TableCell className="font-mono text-xs">
{String(row.id)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{getJobLabel(row)}
</TableCell>
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
<TableCell>
<Badge variant={stateVariants[row.state] ?? "outline"}>
{row.state}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.timestamp)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.processedOn)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.finishedOn)}
</TableCell>
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
{row.failedReason ?? "—"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{hasLink ? (
<Button variant="ghost" size="sm" asChild>
<Link href={pathInfo!.href!}>
<ArrowRight className="size-4 mr-1" />
Service
</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">
</span>
)}
{isCloud &&
row.state === "active" &&
(d?.applicationId != null ||
d?.composeId != null) && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isCancelling}
onClick={() => {
const appId =
typeof d.applicationId === "string"
? d.applicationId
: undefined;
const compId =
typeof d.composeId === "string"
? d.composeId
: undefined;
if (appId) {
void cancelApplicationDeployment({
applicationId: appId,
});
} else if (compId) {
void cancelComposeDeployment({
composeId: compId,
});
}
}}
>
<XCircle className="size-4 mr-1" />
Cancel
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
<ListTodo className="size-8" />
<p className="font-medium">Queue is empty</p>
<p className="text-sm">
Deployment jobs will appear here when they are queued.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -24,7 +24,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const organizationSchema = z.object({
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const { refetch: refetchActiveOrganization } =
authClient.useActiveOrganization();
const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationSchema),
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
utils.organization.all.invalidate();
if (organizationId) {
utils.organization.one.invalidate({ organizationId });
refetchActiveOrganization();
utils.organization.active.invalidate();
}
setOpen(false);
})

View File

@@ -23,7 +23,6 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});
@@ -174,6 +173,14 @@ export const SearchCommand = () => {
>
Projects
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/deployments");
setOpen(false);
}}
>
Deployments
</CommandItem>
{!isCloud && (
<>
<CommandItem

View File

@@ -12,13 +12,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: session } = api.user.session.useQuery();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [data?.id]);
}, [activeOrganization?.id, session?.user?.id]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>

View File

@@ -18,7 +18,6 @@ import {
PushoverIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -165,12 +164,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("teams"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -190,10 +183,6 @@ export const notificationsMap = {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
teams: {
icon: <TeamsIcon className="text-muted-foreground" />,
label: "Microsoft Teams",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -255,8 +244,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
api.notification.testTeamsConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
@@ -291,9 +278,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const teamsMutation = notificationId
? api.notification.updateTeams.useMutation()
: api.notification.createTeams.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
@@ -451,19 +435,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "teams") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
@@ -517,7 +488,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
teams: teamsMutation,
custom: customMutation,
pushover: pushoverMutation,
};
@@ -660,20 +630,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "teams") {
promise = teamsMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
teamsId: notification?.teamsId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
@@ -1509,32 +1465,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "teams" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://xxx.webhook.office.com/webhookb2/..."
{...field}
/>
</FormControl>
<FormDescription>
Incoming Webhook URL from a Teams channel. Add an
Incoming Webhook in your channel settings to get the
URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "pushover" && (
<>
<FormField
@@ -1850,7 +1780,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingTeams ||
isLoadingCustom ||
isLoadingPushover
}
@@ -1912,10 +1841,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "teams") {
await testTeamsConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0

View File

@@ -7,7 +7,6 @@ import {
NtfyIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -38,7 +37,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Teams, Email, Resend, Lark.
Telegram, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -113,11 +112,6 @@ export const ShowNotifications = () => {
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.notificationType === "teams" && (
<div className="flex items-center justify-center rounded-lg">
<TeamsIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -72,6 +73,7 @@ export const ProfileForm = () => {
isError,
error,
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -155,10 +157,10 @@ export const ProfileForm = () => {
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
Account
{t("settings.profile.title")}
</CardTitle>
<CardDescription>
Change the details of your profile here.
{t("settings.profile.description")}
</CardDescription>
</div>
@@ -211,9 +213,12 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -228,7 +233,7 @@ export const ProfileForm = () => {
<FormControl>
<Input
type="password"
placeholder="Current Password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
@@ -242,11 +247,13 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
@@ -261,7 +268,9 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
@@ -445,7 +454,7 @@ export const ProfileForm = () => {
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
Save
{t("settings.common.save")}
</Button>
</div>
</form>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -16,6 +17,7 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
@@ -28,11 +30,13 @@ export const ShowDokployActions = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
Server
{t("settings.server.webServer.server.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -47,17 +51,17 @@ export const ShowDokployActions = () => {
}}
className="cursor-pointer"
>
<span>Reload</span>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<TerminalModal serverId="local">
<span>Terminal</span>
<span>{t("settings.common.enterTerminal")}</span>
</TerminalModal>
<ShowModalLogs appName="dokploy">
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
{t("settings.server.webServer.watchLogs")}
</DropdownMenuItem>
</ShowModalLogs>
<GPUSupportModal />
@@ -66,7 +70,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Update Server IP
{t("settings.server.webServer.updateServerIp")}
</DropdownMenuItem>
</UpdateServerIp>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -15,6 +16,7 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
@@ -62,11 +64,13 @@ export const ShowStorageActions = ({ serverId }: Props) => {
}
variant="outline"
>
Space
{t("settings.server.webServer.storage.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -83,7 +87,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean unused images</span>
<span>
{t("settings.server.webServer.storage.cleanUnusedImages")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -99,7 +105,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean unused volumes</span>
<span>
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -116,7 +124,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean stopped containers</span>
<span>
{t("settings.server.webServer.storage.cleanStoppedContainers")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -133,7 +143,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean Docker Builder & System</span>
<span>
{t("settings.server.webServer.storage.cleanDockerBuilder")}
</span>
</DropdownMenuItem>
{!serverId && (
<DropdownMenuItem
@@ -148,7 +160,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean Monitoring</span>
<span>
{t("settings.server.webServer.storage.cleanMonitoring")}
</span>
</DropdownMenuItem>
)}
@@ -166,7 +180,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean all</span>
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -21,6 +22,7 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
@@ -73,11 +75,13 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
}
variant="outline"
>
Traefik
{t("settings.server.webServer.traefik.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -96,7 +100,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>Reload</span>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
@@ -107,7 +111,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
View Logs
{t("settings.server.webServer.watchLogs")}
</DropdownMenuItem>
</ShowModalLogs>
<EditTraefikEnv serverId={serverId}>
@@ -115,7 +119,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>Modify Environment</span>
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
</DropdownMenuItem>
</EditTraefikEnv>
@@ -172,7 +176,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>Additional Port Mappings</span>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -62,6 +63,8 @@ interface Props {
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -362,7 +365,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -376,7 +379,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormControl>
<Input
placeholder="22"
@@ -406,7 +409,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>

View File

@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -51,6 +52,7 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch, isLoading } = api.server.all.useQuery();

View File

@@ -55,7 +55,7 @@ export const AddInvitation = () => {
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: activeOrganization } = api.organization.active.useQuery();
const form = useForm<AddInvitation>({
defaultValues: {

View File

@@ -37,7 +37,7 @@ export const ShowUsers = () => {
const { mutateAsync } = api.user.remove.useMutation();
const utils = api.useUtils();
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
return (
<div className="w-full">

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -65,6 +66,7 @@ const addServerDomain = z
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -117,10 +119,10 @@ export const WebDomain = () => {
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
Server Domain
{t("settings.server.domain.title")}
</CardTitle>
<CardDescription>
Add a domain to your server application.
{t("settings.server.domain.description")}
</CardDescription>
</div>
</CardHeader>
@@ -149,7 +151,9 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -169,7 +173,9 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -210,20 +216,32 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
@@ -236,7 +254,7 @@ export const WebDomain = () => {
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
Save
{t("settings.common.save")}
</Button>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -14,6 +15,7 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
@@ -27,16 +29,18 @@ export const WebServer = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
Web Server
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>Reload or clean the web server.</CardDescription>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
Web Server
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
Reload or clean the web server.
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -51,6 +52,8 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
const { t } = useTranslation("settings");
const form = useForm<Schema>({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -74,7 +77,9 @@ const LocalServerConfig = ({ onSave }: Props) => {
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-row gap-2 items-center">
<Settings className="h-4 w-4" />
<span className="dark:hover:text-white">Connection settings</span>
<span className="dark:hover:text-white">
{t("settings.terminal.connectionSettings")}
</span>
</div>
</div>
</AccordionTrigger>
@@ -91,7 +96,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormControl>
<Input
{...field}
@@ -119,7 +124,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -137,7 +142,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
Save
{t("settings.common.save")}
</Button>
</AccordionContent>
</AccordionItem>

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
@@ -55,6 +56,7 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
@@ -82,7 +84,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: "Ports updated successfully",
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
onSuccess: () => {
refetchPorts();
setOpen(false);
@@ -127,12 +129,14 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
Additional Port Mappings
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
Add or remove additional ports for Traefik
{t(
"settings.server.webServer.traefik.managePortsDescription",
)}
<span className="text-sm text-muted-foreground">
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -175,7 +179,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
Target Port
{t(
"settings.server.webServer.traefik.targetPort",
)}
</FormLabel>
<FormControl>
<Input
@@ -204,7 +210,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
Published Port
{t(
"settings.server.webServer.traefik.publishedPort",
)}
</FormLabel>
<FormControl>
<Input

View File

@@ -0,0 +1,540 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, ImagePlus, Loader2, Palette, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const formSchema = z.object({
appName: z.string().max(256).optional().or(z.literal("")),
tagline: z.string().max(512).optional().or(z.literal("")),
logoUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
faviconUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
customCss: z.string().max(8192).optional().or(z.literal("")),
});
type FormValues = z.infer<typeof formSchema>;
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const MAX_FAVICON_SIZE_BYTES = 512 * 1024; // 512KB (favicons are small)
/** Reference of CSS variables used by the app (from globals.css). Use in :root for light, .dark for dark mode. Values: HSL without hsl() e.g. 220 70% 50% */
const CSS_VARIABLES_REFERENCE = `/* General */
--background, --foreground
--card, --card-foreground
--popover, --popover-foreground
--primary, --primary-foreground
--secondary, --secondary-foreground
--muted, --muted-foreground
--accent, --accent-foreground
--destructive, --destructive-foreground
--border, --input, --ring
--radius (e.g. 0.5rem)
--overlay (e.g. rgba(0,0,0,0.2))
/* Sidebar */
--sidebar-background, --sidebar-foreground
--sidebar-primary, --sidebar-primary-foreground
--sidebar-accent, --sidebar-accent-foreground
--sidebar-border, --sidebar-ring
/* Charts */
--chart-1, --chart-2, --chart-3, --chart-4, --chart-5`;
/** Default theme CSS (mirrors globals.css). Load into editor so user can edit variables without writing from scratch. */
const DEFAULT_THEME_CSS = `:root {
--terminal-paste: rgba(0, 0, 0, 0.2);
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--overlay: rgba(0, 0, 0, 0.2);
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--terminal-paste: rgba(255, 255, 255, 0.2);
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 240 4% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
--overlay: rgba(0, 0, 0, 0.5);
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 160 60% 45%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
`;
export const WhitelabelForm = () => {
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading } = api.whitelabel.get.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading: isPending } =
api.whitelabel.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
appName: "",
tagline: "",
logoUrl: "",
faviconUrl: "",
customCss: "",
},
});
useEffect(() => {
if (data) {
form.reset({
appName: data.appName ?? "",
tagline: data.tagline ?? "",
logoUrl: data.logoUrl ?? "",
faviconUrl: data.faviconUrl ?? "",
customCss: data.customCss ?? "",
});
}
}, [data, form]);
const onSubmit = async (values: FormValues) => {
await mutateAsync({
appName: values.appName?.trim() || null,
tagline: values.tagline?.trim() || null,
logoUrl: values.logoUrl?.trim() || null,
faviconUrl: values.faviconUrl?.trim() || null,
customCss: values.customCss?.trim() || null,
})
.then(() => {
utils.whitelabel.get.invalidate();
toast.success("Whitelabel settings saved");
})
.catch((error) => {
toast.error(error.message ?? "Failed to save");
});
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Palette className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Whitelabelling</CardTitle>
</div>
<CardDescription>
Customize the app name and logos for your self-hosted instance.
These will appear on the login page, sidebar, and browser tab.
</CardDescription>
</div>
</div>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App name</FormLabel>
<FormControl>
<Input placeholder="e.g. My Company DevOps" {...field} />
</FormControl>
<FormDescription>
Replaces &quot;Dokploy&quot; in the UI when set.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagline"
render={({ field }) => (
<FormItem>
<FormLabel>Tagline</FormLabel>
<FormControl>
<Input
placeholder="e.g. The Open Source alternative to Netlify, Vercel, Heroku."
{...field}
/>
</FormControl>
<FormDescription>
Quote shown on the login/onboarding side panel. Leave empty
for default.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
<Logo
className="size-12 shrink-0"
logoUrl={field.value}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded image
</p>
<p className="text-xs text-muted-foreground">
Image is stored and will appear in sidebar and
login.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
logoFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/logo.png"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => logoFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={logoFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_LOGO_SIZE_BYTES) {
toast.error("Image size must be less than 2MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a logo URL or upload an image (max 2MB). Used in
sidebar and login.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
{field.value && (
// biome-ignore lint/performance/noImgElement: favicon preview from data URL
<img
src={field.value}
alt="Favicon"
className="size-8 shrink-0 object-contain"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded favicon
</p>
<p className="text-xs text-muted-foreground">
Shown in the browser tab.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
faviconFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/favicon.ico"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => faviconFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={faviconFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FAVICON_SIZE_BYTES) {
toast.error(
"Favicon size must be less than 512KB",
);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a favicon URL or upload an image (max 512KB). Shown in
the browser tab.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="customCss"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>Custom CSS</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
form.setValue("customCss", DEFAULT_THEME_CSS, {
shouldDirty: true,
});
toast.success("Default theme loaded. Edit and save.");
}}
>
Load default theme
</Button>
</div>
<FormControl>
<CodeEditor
language="css"
lineWrapping
lineNumbers={false}
wrapperClassName="min-h-[180px] rounded-md border"
placeholder={`:root {
--primary: 220 70% 50%;
--primary-foreground: 0 0% 100%;
}
.dark {
--primary: 220 70% 50%;
}`}
{...field}
/>
</FormControl>
<FormDescription>
Optional. Override theme colors using CSS variables. Use{" "}
<code className="rounded bg-muted px-1">:root</code> for
light mode and{" "}
<code className="rounded bg-muted px-1">.dark</code> for
dark mode. Values in HSL without &quot;hsl()&quot;.
Don&apos;t use quotes around colors (e.g.{" "}
<code className="rounded bg-muted px-1">red</code>, not
&quot;red&quot;). Max 8KB.
</FormDescription>
<Collapsible className="mt-2 group">
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
Available CSS variables
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 rounded-md border bg-muted p-3 text-xs font-mono overflow-x-auto whitespace-pre text-muted-foreground">
{CSS_VARIABLES_REFERENCE}
</pre>
</CollapsibleContent>
</Collapsible>
</FormItem>
)}
/>
{form.watch("logoUrl") && (
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-sm text-muted-foreground mb-2">
Logo preview
</p>
<Logo
className="size-12"
logoUrl={form.watch("logoUrl") || undefined}
/>
</div>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</form>
</Form>
)}
</div>
);
};

View File

@@ -88,35 +88,6 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const TeamsIcon = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="36"
viewBox="0 0 512 476"
className={cn("size-9", className)}
>
<g>
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
<text
x="180"
y="270"
fill="#fff"
font-family="Segoe UI, Arial, sans-serif"
font-size="110"
font-weight="bold"
>
T
</text>
</g>
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -1,14 +1,23 @@
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
const DEFAULT_TAGLINE =
"The Open Source alternative to Netlify, Vercel, Heroku.";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
const appName = whitelabel?.appName ?? "Dokploy";
const logoUrl = whitelabel?.logoUrl ?? undefined;
const tagline = whitelabel?.tagline ?? DEFAULT_TAGLINE;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
@@ -17,15 +26,12 @@ export const OnboardingLayout = ({ children }: Props) => {
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<Logo className="size-10" logoUrl={logoUrl} />
{appName}
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg text-primary">
&ldquo;The Open Source alternative to Netlify, Vercel,
Heroku.&rdquo;
</p>
<p className="text-lg text-primary">&ldquo;{tagline}&rdquo;</p>
</blockquote>
</div>
</div>

View File

@@ -24,7 +24,9 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Rocket,
Server,
ShieldCheck,
Star,
@@ -145,6 +147,12 @@ const MENU: Menu = {
url: "/dashboard/projects",
icon: Folder,
},
{
isSingle: true,
title: "Deployments",
url: "/dashboard/deployments",
icon: Rocket,
},
{
isSingle: true,
title: "Monitoring",
@@ -406,6 +414,15 @@ const MENU: Menu = {
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Whitelabelling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Proprietary: only in non-cloud, admins only
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
title: "SSO",
@@ -538,8 +555,11 @@ function LogoWrapper() {
function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery(undefined, {
enabled: !isCloud,
});
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const {
data: organizations,
refetch,
@@ -550,8 +570,7 @@ function SidebarLogo() {
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
@@ -610,7 +629,11 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
logoUrl={
activeOrganization?.logo ||
whitelabel?.logoUrl ||
undefined
}
/>
</div>
<div
@@ -620,7 +643,9 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
{activeOrganization?.name ??
whitelabel?.appName ??
"Select Organization"}
</p>
</div>
</div>

View File

@@ -10,9 +10,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
@@ -23,6 +32,7 @@ export const UserNav = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
@@ -145,19 +155,39 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="w-32">
<Select
onValueChange={setLocale}
defaultValue={locale}
value={locale}
>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -84,10 +84,9 @@ export const SSOSettings = () => {
const [newOriginInput, setNewOriginInput] = useState("");
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
undefined,
{ enabled: manageOriginsOpen },
);
const { data: userData } = api.user.get.useQuery(undefined, {
enabled: manageOriginsOpen,
});
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
@@ -97,6 +96,8 @@ export const SSOSettings = () => {
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
api.sso.updateTrustedOrigin.useMutation();
const trustedOrigins = userData?.user?.trustedOrigins ?? [];
const handleAddOrigin = async () => {
const value = newOriginInput.trim();
if (!value) return;
@@ -104,7 +105,7 @@ export const SSOSettings = () => {
await addTrustedOrigin({ origin: value });
toast.success("Trusted origin added");
setNewOriginInput("");
await utils.sso.getTrustedOrigins.invalidate();
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to add trusted origin",
@@ -117,7 +118,7 @@ export const SSOSettings = () => {
await removeTrustedOrigin({ origin });
toast.success("Trusted origin removed");
if (editingOrigin === origin) setEditingOrigin(null);
await utils.sso.getTrustedOrigins.invalidate();
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove trusted origin",
@@ -143,7 +144,7 @@ export const SSOSettings = () => {
toast.success("Trusted origin updated");
setEditingOrigin(null);
setEditingValue("");
await utils.sso.getTrustedOrigins.invalidate();
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update trusted origin",

View File

@@ -0,0 +1,27 @@
"use client";
import Head from "next/head";
import { api } from "@/utils/api";
/** When whitelabel is configured (non-cloud), overrides document title, favicon, and optional custom CSS. */
export function WhitelabelHead() {
const { data: whitelabel } = api.whitelabel.get.useQuery();
// Always render so we can override favicon (same key as _app default replaces it)
return (
<Head>
{whitelabel?.appName && <title>{whitelabel.appName}</title>}
{whitelabel?.faviconUrl ? (
<link rel="icon" href={whitelabel.faviconUrl} key="favicon" />
) : (
<link rel="icon" href="/icon.svg" key="favicon" />
)}
{whitelabel?.customCss?.trim() && (
<style
id="whitelabel-custom-css"
dangerouslySetInnerHTML={{ __html: whitelabel.customCss.trim() }}
/>
)}
</Head>
);
}

View File

@@ -4,12 +4,14 @@ import {
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view";
import { search, searchKeymap } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
@@ -130,7 +132,7 @@ function dockerComposeComplete(
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties" | "shell";
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
@@ -155,6 +157,8 @@ export const CodeEditor = ({
}}
theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
: language === "json"

View File

@@ -1,15 +1,17 @@
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
className?: string;
logoUrl?: string;
}
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
export const Logo = ({ className = "size-14" }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
if (whitelabel?.logoUrl) {
return (
<img
src={logoUrl}
src={whitelabel?.logoUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>

View File

@@ -1,8 +0,0 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
CREATE TABLE "teams" (
"teamsId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1013,8 +1013,22 @@
{
"idx": 144,
"version": "7",
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"when": 1771222951607,
"tag": "0144_flaky_psylocke",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1771226249421,
"tag": "0145_modern_risque",
"breakpoints": true
},
{
"idx": 146,
"version": "7",
"when": 1771226541422,
"tag": "0146_last_kitty_pryde",
"breakpoints": true
}
]

View File

@@ -0,0 +1,29 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = {
english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" },
persian: { code: "fa", name: "فارسی" },
dutch: { code: "nl", name: "Nederlands" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
kazakh: { code: "kz", name: "Қазақ" },
norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" },
malayalam: { code: "ml", name: "മലയാളം" },
};
export type Language = keyof typeof Languages;
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];

View File

@@ -10,6 +10,15 @@ const nextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.27.1",
"version": "v0.28.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -41,19 +41,21 @@
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0",
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1",
@@ -95,8 +97,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -113,6 +115,7 @@
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
@@ -120,6 +123,7 @@
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -137,6 +141,7 @@
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"slugify": "^1.6.6",
@@ -144,7 +149,7 @@
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.31.1",
"swagger-ui-react": "^5.22.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
@@ -153,7 +158,7 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.76",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},

View File

@@ -4,11 +4,14 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelHead } from "@/components/proprietary/whitelabel-head";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
const inter = Inter({ subsets: ["latin"] });
@@ -24,9 +27,10 @@ type AppPropsWithLayout = AppProps & {
const MyApp = ({
Component,
pageProps: { ...pageProps },
pageProps: { whitelabelFaviconUrl, ...pageProps },
}: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page);
const faviconHref = whitelabelFaviconUrl ?? "/icon.svg";
return (
<>
@@ -39,7 +43,9 @@ const MyApp = ({
</style>
<Head>
<title>Dokploy</title>
<link rel="icon" href={faviconHref} key="favicon" />
</Head>
<WhitelabelHead />
<ThemeProvider
attribute="class"
defaultTheme="system"
@@ -56,4 +62,14 @@ const MyApp = ({
);
};
export default api.withTRPC(MyApp);
export default api.withTRPC(
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
}),
);

View File

@@ -4,7 +4,7 @@ export default function Document() {
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />

View File

@@ -152,10 +152,6 @@ export default async function handler(
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "soft-serve") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
@@ -443,13 +439,6 @@ export const extractCommitMessage = (headers: any, body: any) => {
: "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.commits && body.commits.length > 0
? body.commits[0].message
: "NEW COMMIT";
}
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
@@ -487,11 +476,6 @@ export const extractHash = (headers: any, body: any) => {
return body.after || "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.after || "NEW COMMIT";
}
return "";
};
@@ -500,10 +484,7 @@ export const extractBranchName = (headers: any, body: any) => {
return body?.ref?.replace("refs/heads/", "");
}
if (
headers["x-gitlab-event"] ||
headers["x-softserve-event"]?.includes("push")
) {
if (headers["x-gitlab-event"]) {
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
}
@@ -531,10 +512,6 @@ export const getProviderByHeader = (headers: any) => {
return "bitbucket";
}
if (headers["x-softserve-event"]) {
return "soft-serve";
}
return null;
};

View File

@@ -10,22 +10,29 @@ type Query = {
state: string;
installation_id: string;
setup_action: string;
userId: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { code, state, installation_id, userId }: Query = req.query as Query;
const { code, state, installation_id }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, value] = state?.split(":");
// Value could be the organizationId or the githubProviderId
const [action, ...rest] = state?.split(":");
// For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({});
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
@@ -44,7 +51,7 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
value as string,
organizationId as string,
userId,
);
} else if (action === "gh_setup") {
@@ -53,7 +60,7 @@ export default async function handler(
.set({
githubInstallationId: installation_id,
})
.where(eq(github.githubId, value as string))
.where(eq(github.githubId, rest[0] as string))
.returning();
}

View File

@@ -0,0 +1,94 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { Rocket } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TAB_VALUES = ["deployments", "queue"] as const;
type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(t: string): t is TabValue {
return TAB_VALUES.includes(t as TabValue);
}
function DeploymentsPage() {
const router = useRouter();
const tab =
router.query.tab && isValidTab(router.query.tab as string)
? (router.query.tab as TabValue)
: "deployments";
const setTab = (value: string) => {
if (!isValidTab(value)) return;
router.replace(
{ pathname: "/dashboard/deployments", query: { tab: value } },
undefined,
{ shallow: true },
);
};
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<Rocket className="size-5" />
Deployments
</CardTitle>
<CardDescription>
All application and compose deployments in one place.
</CardDescription>
</div>
</div>
<Tabs value={tab} onValueChange={setTab} className="w-full">
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 pt-4">
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
</TabsContent>
</Tabs>
</CardHeader>
</div>
</Card>
</div>
);
}
export default DeploymentsPage;
DeploymentsPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -785,7 +785,7 @@ const EnvironmentPage = (
}
if (success > 0) {
toast.success(
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
);
}
if (failed > 0) {

View File

@@ -90,6 +90,7 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
@@ -121,7 +122,8 @@ const Service = (
/>
<Head>
<title>
Application: {data?.name} - {data?.environment.project.name} | Dokploy
Application: {data?.name} - {data?.environment.project.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">
@@ -165,7 +167,7 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
{data?.server?.name || `${whitelabel?.appName ?? "Dokploy"} Server`}
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>

View File

@@ -79,6 +79,7 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
@@ -110,7 +111,8 @@ const Service = (
/>
<Head>
<title>
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
Compose: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -59,6 +59,7 @@ const Mariadb = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -93,8 +94,8 @@ const Mariadb = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">

View File

@@ -59,6 +59,7 @@ const Mongo = (
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,7 +92,8 @@ const Mongo = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const MySql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,8 +92,8 @@ const MySql = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const Postgresql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -90,7 +91,8 @@ const Postgresql = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const Redis = (
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -90,7 +91,8 @@ const Redis = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -6,6 +6,7 @@ import superjson from "superjson";
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -25,6 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -53,6 +55,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -7,6 +7,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -34,6 +35,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -68,6 +70,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -9,6 +9,7 @@ import { ProfileForm } from "@/components/dashboard/settings/profile/profile-for
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data } = api.user.get.useQuery();
@@ -36,6 +37,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
@@ -65,6 +67,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,6 +10,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data: user } = api.user.get.useQuery();
@@ -41,6 +42,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
@@ -83,6 +85,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -6,6 +6,7 @@ import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -24,6 +25,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req);
if (!user) {
return {
@@ -59,6 +61,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -8,6 +8,7 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -42,6 +43,7 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -76,6 +78,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -0,0 +1,96 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { WhitelabelForm } from "@/components/dashboard/settings/whitelabelling/whitelabel-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Whitelabelling",
description:
"Customise app name and logos (whitelabelling) is part of Dokploy Enterprise. Add a valid license to use it.",
ctaLabel: "Go to License",
}}
>
<WhitelabelForm />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => (
<DashboardLayout metaName="Whitelabelling">{page}</DashboardLayout>
);
export async function getServerSideProps(
ctx: GetServerSidePropsContext<Record<string, string>>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role !== "owner" && user.role !== "admin") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: null as any,
user: user as any,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -1,6 +1,7 @@
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -8,6 +9,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
@@ -40,6 +42,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const LoginSchema = z.object({
@@ -59,6 +62,7 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -105,6 +109,7 @@ export default function Home({ IS_CLOUD }: Props) {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
@@ -253,6 +258,7 @@ export default function Home({ IS_CLOUD }: Props) {
onChange={setTwoFactorCode}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
autoFocus
>
<InputOTPGroup>
@@ -433,9 +439,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: context.req as any,
res: context.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
const whitelabel = await helpers.whitelabel.get.fetch();
return {
props: {
hasAdmin,
trpcState: helpers.dehydrate(),
whitelabelFaviconUrl: whitelabel?.faviconUrl ?? null,
},
};
}

View File

@@ -1,11 +1,13 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -22,6 +24,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const registerSchema = z
@@ -91,6 +94,7 @@ const Invitation = ({
initialData: invitation,
},
);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const form = useForm<Register>({
defaultValues: {
@@ -346,6 +350,21 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
try {
const invitation = await getUserByToken(token);
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: ctx.req as any,
res: ctx.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
if (!IS_CLOUD) {
await helpers.whitelabel.get.prefetch();
}
if (invitation.userAlreadyExists) {
return {
props: {
@@ -353,6 +372,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
token: token,
invitation: invitation,
userAlreadyExists: true,
trpcState: helpers.dehydrate(),
},
};
}
@@ -371,6 +391,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
trpcState: helpers.dehydrate(),
},
};
} catch (error) {

View File

@@ -22,6 +22,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const loginSchema = z.object({
email: z
@@ -48,6 +49,7 @@ export default function Home() {
});
const [error, setError] = useState<string | null>(null);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const _router = useRouter();
const form = useForm<Login>({
@@ -82,7 +84,9 @@ export default function Home() {
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">Dokploy</span>
<span className="font-medium text-sm">
{whitelabel?.appName ?? "Dokploy"}
</span>
</Link>
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
<CardDescription>

View File

@@ -24,6 +24,7 @@ import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelRouter } from "./routers/proprietary/whitelabel";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
@@ -86,6 +87,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
whitelabel: whitelabelRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,

View File

@@ -1,7 +1,6 @@
import {
addNewService,
checkServiceAccess,
clearOldDeployments,
createApplication,
deleteAllMiddlewares,
findApplicationById,
@@ -747,23 +746,6 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this application",
});
}
await clearOldDeployments(application.appName, application.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,7 +2,6 @@ import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
createCommand,
createCompose,
@@ -264,23 +263,6 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this compose",
});
}
await clearOldDeployments(compose.appName, compose.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -4,11 +4,15 @@ import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
findAllDeploymentsByServerId,
findAllDeploymentsCentralized,
findApplicationById,
findComposeById,
findDeploymentById,
findMemberById,
findServerById,
IS_CLOUD,
removeDeployment,
resolveServicePath,
updateDeploymentStatus,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -21,7 +25,10 @@ import {
apiFindAllByServer,
apiFindAllByType,
deployments,
server,
} from "@/server/db/schema";
import { myQueue } from "@/server/queues/queueSetup";
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const deploymentRouter = createTRPCRouter({
@@ -68,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({
}
return await findAllDeploymentsByServerId(input.serverId);
}),
allCentralized: protectedProcedure.query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
ctx.user.role === "member"
? (await findMemberById(ctx.user.id, orgId)).accessedServices
: null;
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
return findAllDeploymentsCentralized(orgId, accessedServices);
}),
queueList: protectedProcedure.query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
let rows: QueueJobRow[];
if (IS_CLOUD) {
const servers = await db.query.server.findMany({
where: eq(server.organizationId, orgId),
columns: { serverId: true },
});
const serverRowsArrays = await Promise.all(
servers.map(({ serverId }) => fetchDeployApiJobs(serverId)),
);
rows = serverRowsArrays.flat();
rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
} else {
const jobs = await myQueue.getJobs();
const jobRows = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return {
id: String(job.id),
name: job.name ?? undefined,
data: job.data as Record<string, unknown>,
timestamp: job.timestamp,
processedOn: job.processedOn,
finishedOn: job.finishedOn,
failedReason: job.failedReason ?? undefined,
state,
};
}),
);
jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
rows = jobRows;
}
return Promise.all(
rows.map(async (row) => ({
...row,
servicePath: await resolveServicePath(
orgId,
(row.data ?? {}) as Record<string, unknown>,
),
})),
);
}),
allByType: protectedProcedure
.input(apiFindAllByType)
@@ -79,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({
rollback: true,
},
});
return deploymentsList;
}),
killProcess: protectedProcedure
.input(
z.object({
@@ -108,14 +170,4 @@ export const deploymentRouter = createTRPCRouter({
await updateDeploymentStatus(deployment.deploymentId, "error");
}),
removeDeployment: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return await removeDeployment(input.deploymentId);
}),
});

View File

@@ -21,8 +21,7 @@ export const mountRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateMount)
.mutation(async ({ input }) => {
await createMount(input);
return true;
return await createMount(input);
}),
remove: protectedProcedure
.input(apiRemoveMount)

View File

@@ -8,7 +8,6 @@ import {
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
@@ -24,7 +23,6 @@ import {
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
@@ -35,7 +33,6 @@ import {
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -58,7 +55,6 @@ import {
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
@@ -70,7 +66,6 @@ import {
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
@@ -81,7 +76,6 @@ import {
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
@@ -419,7 +413,6 @@ export const notificationRouter = createTRPCRouter({
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -712,61 +705,6 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createTeams: adminProcedure
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamsNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: adminProcedure
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testTeamsConnection: adminProcedure
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {

View File

@@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({
return { success: true };
}),
active: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.activeOrganizationId) {
return null;
}
return await db.query.organization.findFirst({
where: eq(organization.id, ctx.session.activeOrganizationId),
});
}),
});

View File

@@ -1,5 +1,5 @@
import { user } from "@dokploy/server/db/schema";
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
import { validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
@@ -184,7 +184,18 @@ export const licenseKeyRouter = createTRPCRouter({
};
}),
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
return await hasValidLicense(ctx.session.activeOrganizationId);
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
}),
updateEnterpriseSettings: adminProcedure
.input(

View File

@@ -2,10 +2,7 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import {
getOrganizationOwnerId,
requestToHeaders,
} from "@dokploy/server/index";
import { requestToHeaders } from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -62,17 +59,6 @@ export const ssoRouter = createTRPCRouter({
});
return providers;
}),
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) return [];
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
return ownerUser?.trustedOrigins ?? [];
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
@@ -149,20 +135,11 @@ export const ssoRouter = createTRPCRouter({
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
const trustedOrigins = currentUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
@@ -171,7 +148,7 @@ export const ssoRouter = createTRPCRouter({
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
"The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.",
});
}
}
@@ -285,21 +262,12 @@ export const ssoRouter = createTRPCRouter({
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const existing = currentUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
@@ -307,34 +275,25 @@ export const ssoRouter = createTRPCRouter({
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const existing = currentUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
@@ -345,29 +304,20 @@ export const ssoRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const existing = currentUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
});

View File

@@ -0,0 +1,61 @@
import { getWebServerSettings, updateWebServerSettings } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { apiUpdateWhitelabelSettings } from "@dokploy/server/db/schema";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
export const whitelabelRouter = createTRPCRouter({
get: publicProcedure.query(async () => {
if (IS_CLOUD) return null;
const settings = await getWebServerSettings();
if (!settings) return null;
return {
appName: settings.whitelabelAppName ?? null,
logoUrl: settings.whitelabelLogoUrl ?? null,
faviconUrl: settings.whitelabelFaviconUrl ?? null,
tagline: settings.whitelabelTagline ?? null,
customCss: settings.whitelabelCustomCss ?? null,
};
}),
update: enterpriseProcedure
.input(apiUpdateWhitelabelSettings)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
throw new Error(
"Whitelabelling is only available in self-hosted (non-cloud) installations.",
);
}
await updateWebServerSettings({
...(input.appName !== undefined && {
whitelabelAppName: input.appName,
}),
...(input.tagline !== undefined && {
whitelabelTagline: input.tagline,
}),
...(input.logoUrl !== undefined && {
whitelabelLogoUrl: input.logoUrl,
}),
...(input.faviconUrl !== undefined && {
whitelabelFaviconUrl: input.faviconUrl,
}),
...(input.customCss !== undefined && {
whitelabelCustomCss: input.customCss,
}),
});
const settings = await getWebServerSettings();
return {
appName: settings?.whitelabelAppName ?? null,
logoUrl: settings?.whitelabelLogoUrl ?? null,
faviconUrl: settings?.whitelabelFaviconUrl ?? null,
tagline: settings?.whitelabelTagline ?? null,
customCss: settings?.whitelabelCustomCss ?? null,
};
}),
});

View File

@@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({
// Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) {
const conflictingContainer = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"`
const conflictInfo = portCheck.conflictingContainer
? ` by ${portCheck.conflictingContainer}`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({

View File

@@ -101,6 +101,16 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
session: protectedProcedure.query(async ({ ctx }) => {
return {
user: {
id: ctx.user.id,
},
session: {
activeOrganizationId: ctx.session.activeOrganizationId,
},
};
}),
get: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(

View File

@@ -7,7 +7,6 @@
* need to use are documented accordingly near the end.
*/
import { hasValidLicense } from "@dokploy/server/index";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
@@ -240,11 +239,10 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const hasValidLicenseResult = await hasValidLicense(
ctx.session.activeOrganizationId,
);
if (!hasValidLicenseResult) {
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",

View File

@@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
throw error;
}
};
export type QueueJobRow = {
id: string;
name?: string;
data: Record<string, unknown>;
timestamp?: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
export const fetchDeployApiJobs = async (
serverId: string,
): Promise<QueueJobRow[]> => {
try {
const res = await fetch(
`${process.env.SERVER_URL}/jobs?serverId=${encodeURIComponent(serverId)}`,
{
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.API_KEY || "NO-DEFINED",
},
},
);
if (!res.ok) return [];
return (await res.json()) as QueueJobRow[];
} catch {
return [];
}
};

View File

@@ -3,13 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import {
getShell,
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "./utils";
import { getShell, isValidContainerId } from "./utils";
export const setupDockerContainerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -36,9 +30,9 @@ export const setupDockerContainerLogsWebSocketServer = (
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const containerId = url.searchParams.get("containerId");
const tail = url.searchParams.get("tail") ?? "100";
const search = url.searchParams.get("search") ?? "";
const since = url.searchParams.get("since") ?? "all";
const tail = url.searchParams.get("tail");
const search = url.searchParams.get("search");
const since = url.searchParams.get("since");
const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const { user, session } = await validateRequest(req);
@@ -54,21 +48,6 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
if (!isValidTail(tail)) {
ws.close(4000, "Invalid tail parameter");
return;
}
if (!isValidSince(since)) {
ws.close(4000, "Invalid since parameter");
return;
}
if (search !== "" && !isValidSearch(search)) {
ws.close(4000, "Invalid search parameter");
return;
}
if (!user || !session) {
ws.close();
return;

View File

@@ -1,9 +1,9 @@
import { spawn } from "node:child_process";
import type http from "node:http";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { readValidDirectory } from "@dokploy/server/wss/utils";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { readValidDirectory } from "./utils";
export const setupDeploymentLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,

View File

@@ -15,37 +15,6 @@ export const isValidContainerId = (id: string): boolean => {
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
};
/**
* Validates the `tail` parameter for docker logs (number of lines, max 10000).
* Prevents command injection by allowing only digits.
*/
export const isValidTail = (tail: string): boolean => {
return (
/^\d+$/.test(tail) &&
Number.parseInt(tail, 10) <= 10000 &&
Number.parseInt(tail, 10) >= 0
);
};
/**
* Validates the `since` parameter for docker logs: "all" or duration like 5s, 10m, 1h, 2d.
* Prevents command injection by allowing only a strict format.
*/
export const isValidSince = (since: string): boolean => {
return since === "all" || /^\d+[smhd]$/.test(since);
};
/**
* Validates the `search` parameter for log filtering.
* Search is concatenated into shell commands (SSH path: double quotes; local path: single quotes).
* Only allow alphanumeric, space, dot, underscore, hyphen to prevent $, `, ', " from enabling command injection.
* Max length 500.
*/
export const isValidSearch = (search: string): boolean => {
// Space only (not \s) to reject \n, \r, \t and other control chars
return /^[a-zA-Z0-9 ._-]{0,500}$/.test(search);
};
/**
* Validates that the shell is one of the allowed shells.
*/
@@ -63,6 +32,20 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell);
};
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);
return (
resolvedDir === resolvedBase ||
resolvedDir.startsWith(resolvedBase + path.sep)
);
};
export const getShell = () => {
if (IS_CLOUD) {
return "NO_AVAILABLE";

View File

@@ -39,7 +39,8 @@
"**/*.js",
".next/types/**/*.ts",
"env.js",
"next.config.mjs"
"next.config.mjs",
"next-i18next.config.mjs"
],
"exclude": [
"node_modules",

View File

@@ -0,0 +1,16 @@
import Cookies from "js-cookie";
import type { LanguageCode } from "@/lib/languages";
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
const setLocale = (locale: LanguageCode) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};
return {
locale: currentLocale,
setLocale,
};
}

View File

@@ -0,0 +1,23 @@
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
export function getLocale(cookies: NextApiRequestCookies) {
const locale = cookies.DOKPLOY_LOCALE ?? "en";
return locale;
}
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import { Languages } from "@/lib/languages";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
});

View File

@@ -58,13 +58,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name = ?
WHERE container_name = ? OR container_name LIKE ?
ORDER BY timestamp DESC
LIMIT ?
)
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
`
rows, err := db.Query(query, containerName, limit)
rows, err := db.Query(query, containerName, containerName+".%", limit)
if err != nil {
return nil, err
}
@@ -98,12 +98,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name = ?
WHERE container_name = ? OR container_name LIKE ?
ORDER BY timestamp DESC
)
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
`
rows, err := db.Query(query, containerName)
rows, err := db.Query(query, containerName, containerName+".%")
if err != nil {
return nil, err
}

View File

@@ -20,7 +20,7 @@
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.25.76"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -43,10 +43,5 @@
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2"
}
}
}

Some files were not shown because too many files have changed in this diff Show More