Compare commits

..

147 Commits

Author SHA1 Message Date
Mauricio Siu
d420311507 docs: Update CONTRIBUTING.md and pull request template for clarity
- Corrected a typo in the CONTRIBUTING.md file, changing "comunity" to "community."
- Added a new section in CONTRIBUTING.md titled "Important Considerations for Pull Requests" to emphasize the necessity of testing before submission.
- Enhanced the pull request template to remind contributors to test their changes locally before submitting, ensuring a smoother review process.
2026-02-08 23:13:19 -06:00
Mauricio Siu
a01ace12e8 Merge pull request #3434 from bdkopen/update-subdependencies
chore: Resolve CVEs by Updating `swagger-react-ui` & `octokit` Sub-dependencies
2026-02-08 21:01:24 -06:00
Mauricio Siu
24c022f837 Merge pull request #3647 from Dokploy/2659-upgrading-dokploy-admin-resulted-in-bad-gateway-500-consistently
feat(dokploy): add wait-for-postgres script and update Dockerfile and…
2026-02-08 16:22:47 -06:00
Mauricio Siu
ecd81eb7fa fix(dokploy): remove wait-for-postgres script from start command in package.json
- Updated the start script in package.json to eliminate the wait-for-postgres script, streamlining the application startup process.
- This change ensures that the migration and server processes are initiated directly without the wait-for-postgres dependency.
2026-02-08 16:22:26 -06:00
Mauricio Siu
f2e4a96154 feat(dokploy): add wait-for-postgres script and update Dockerfile and package.json
- Introduced a new script to wait for PostgreSQL to be ready before starting the application.
- Updated the Dockerfile to include a health check for the application.
- Modified the start script in package.json to run the wait-for-postgres script prior to starting the server and migration processes.
- Added the wait-for-postgres TypeScript file to handle connection retries to the PostgreSQL database.
2026-02-08 15:43:58 -06:00
Mauricio Siu
08ba24c252 fix(auth): update BETTER_AUTH_SECRET default value for legacy support
- Changed the default value of BETTER_AUTH_SECRET to ensure compatibility for users who enabled 2FA before the introduction of the new secret.
- This update maintains existing authentication functionality while transitioning to a more secure default.

close https://github.com/Dokploy/dokploy/issues/3645
2026-02-08 13:32:37 -06:00
Mauricio Siu
ff55270b52 refactor(auth): conditionally apply advanced cookie settings based on cloud environment
- Updated the authentication configuration to conditionally include advanced cookie settings only when not in a cloud environment.
- This change enhances flexibility in cookie management while maintaining existing security practices.
2026-02-08 04:16:03 -06:00
Mauricio Siu
f78819d81a feat(auth): add advanced cookie settings for better security management
- Introduced advanced cookie settings in the authentication configuration, including options for secure cookies and default cookie attributes.
- This enhancement aims to improve security practices related to cookie handling in the application.
2026-02-08 04:02:04 -06:00
Mauricio Siu
79e02483ad Merge pull request #3643 from Dokploy/fix/add-migration-command-on-start
feat(migration): add migration entry point and update start script
2026-02-08 03:19:48 -06:00
Mauricio Siu
f25ed46dbc feat(migration): add migration entry point and update start script
- Added a new entry point for migration in the esbuild configuration.
- Updated the start script in package.json to run the migration before starting the server.
- Removed the direct migration call from the server initialization process to streamline the workflow.
2026-02-08 03:18:44 -06:00
Mauricio Siu
7ad09c0d0d Merge pull request #3642 from Dokploy/3469-multiple-preview-deployments-regression-in-v026x-same-as-1198
3469 multiple preview deployments regression in v026x same as 1198
2026-02-08 01:53:41 -06:00
Mauricio Siu
a212d42495 refactor(database): remove unique constraint and related files for preview_deployments
- Deleted the SQL file that added a unique constraint on the combination of applicationId and pullRequestId in the preview_deployments table.
- Removed the corresponding entry from the _journal.json to reflect the deletion of the migration.
- Deleted the snapshot file for version 142, which is no longer needed.
- Updated the preview-deployments schema to remove the unique index, reverting to the previous state.
2026-02-08 01:51:02 -06:00
Mauricio Siu
51095e3ac5 feat(database): add unique constraint to preview_deployments table and update schema
- Introduced a new SQL file to add a unique constraint on the combination of applicationId and pullRequestId in the preview_deployments table.
- Updated the _journal.json to include the new migration entry for version 142.
- Created a new snapshot file for version 142 to reflect the current database schema.
- Modified the preview-deployments schema to include a unique index for applicationId and pullRequestId, enhancing data integrity.
2026-02-08 01:28:23 -06:00
Mauricio Siu
a897fe6115 Merge pull request #3640 from Dokploy/3636-bug-build-server-env-file-creation-fails---no-such-file-or-directory-on-remote-build-server
3636 bug build server env file creation fails   no such file or directory on remote build server
2026-02-07 23:35:22 -06:00
Mauricio Siu
a0d9f06a35 fix(logs): ensure safe access to service error in ShowDockerLogs component
- Updated the ShowDockerLogs component to use optional chaining when accessing the error property of services, preventing potential runtime errors.
- Refactored the deployApplication function to create an applicationEntity object, ensuring consistent use of serverId across repository cloning functions.
- Removed unused createEnvFile function from utils, streamlining the codebase.
2026-02-07 23:34:35 -06:00
Mauricio Siu
f1d0fb95f4 feat(deployment-logs): enhance readValidDirectory function to accept serverId parameter
- Updated the readValidDirectory function to include an optional serverId parameter, improving its flexibility for directory validation.
- Modified the deployment logs WebSocket server setup to utilize the updated readValidDirectory function, ensuring proper log path validation based on server context.
2026-02-07 23:19:33 -06:00
Mauricio Siu
4bc494e009 Merge pull request #3637 from Dokploy/3392-conflict-between-daily-docker-cleanup-and-volume-backup-turn-off-container-during-backup
3392 conflict between daily docker cleanup and volume backup turn off container during backup
2026-02-07 23:03:24 -06:00
Mauricio Siu
110bdce38c feat(schedules): replace hardcoded cron schedule with CLEANUP_CRON_JOB constant
- Updated the cron schedule for Docker cleanup tasks across multiple files to use the new CLEANUP_CRON_JOB constant.
- This change enhances maintainability by centralizing the cron schedule configuration, ensuring consistency across the application.
2026-02-07 22:58:21 -06:00
Mauricio Siu
b9e700243e feat(volume-backups): implement volume backup locking mechanism
- Added a locking mechanism to prevent concurrent volume backups, ensuring data integrity during backup operations.
- Introduced a `lockWrapper` function that manages the locking process using either `flock` or directory-based locking.
- Updated the `backupVolume` function to utilize the locking mechanism for both application and compose service types, enhancing the reliability of backup processes.
2026-02-07 22:03:16 -06:00
Mauricio Siu
bc39addfa8 feat(volume-backups): enhance query to order backups by creation date
- Updated the volume backups query to include ordering by the `createdAt` field in descending order.
- This change improves the retrieval of backup records, ensuring the most recent backups are prioritized in the response.
2026-02-07 22:01:02 -06:00
Mauricio Siu
2532934cdf fix(settings): correct database query syntax in reconnectServicesToTraefik function
- Updated the database query in the `reconnectServicesToTraefik` function to remove optional chaining, ensuring proper execution of the query.
- This change enhances the reliability of service reconnections to Traefik by ensuring the database connection is correctly utilized.
2026-02-07 20:06:33 -06:00
Mauricio Siu
f4b5a589b6 Merge pull request #3635 from Dokploy/3313-traefik-loses-all-bridge-network-connections-when-modifying-port-mappings-or-toggling-dashboard
feat(traefik): implement reconnectServicesToTraefik function
2026-02-07 19:44:41 -06:00
Mauricio Siu
105562bdcb feat(traefik): implement reconnectServicesToTraefik function
- Added a new function `reconnectServicesToTraefik` to facilitate the reconnection of services to Traefik based on the server ID.
- The function queries the database for isolated deployments and constructs Docker network connect commands for each service.
- Enhanced the existing Traefik setup process by ensuring services are properly reconnected after setup.
2026-02-07 19:42:14 -06:00
Mauricio Siu
16359e21a2 Merge pull request #3624 from horsley/fix/setup-import-side-effect
fix: avoid database connection during setup by using native exec
2026-02-07 15:53:27 -06:00
Mauricio Siu
9451958193 Merge pull request #3632 from Dokploy/1591-gitea-git-provider-fails-after-the-authorize-application-stage-self-hosted-on-private-network
feat(gitea): add optional internal URL for Gitea integration
2026-02-07 13:01:37 -06:00
autofix-ci[bot]
1a810790cd [autofix.ci] apply automated fixes 2026-02-07 19:01:22 +00:00
Mauricio Siu
e426c89cb2 feat(gitea): add optional internal URL for Gitea integration
- Introduced a new field `giteaInternalUrl` in the Gitea provider settings to allow users to specify an internal URL for OAuth token exchange when Gitea runs on the same instance as Dokploy.
- Updated the Gitea provider forms to include the new field with appropriate descriptions.
- Modified the token exchange logic to utilize the internal URL if provided, enhancing connectivity options for users.
- Updated database schema to accommodate the new field.
2026-02-07 13:00:47 -06:00
Mauricio Siu
325a0aeedf Merge pull request #3631 from Dokploy/3223-dokploy-selfhosted-gitlab-ce-selfhosted-error-postgre
feat(gitlab): add optional internal URL for GitLab integration
2026-02-07 12:54:29 -06:00
autofix-ci[bot]
a8293b7b5c [autofix.ci] apply automated fixes 2026-02-07 18:52:44 +00:00
Mauricio Siu
54bd25da39 feat(gitlab): add optional internal URL for GitLab integration
- Introduced a new field `gitlabInternalUrl` in the GitLab provider settings to allow users to specify an internal URL for OAuth token exchange when GitLab runs on the same instance as Dokploy.
- Updated the GitLab provider forms to include the new field with appropriate descriptions.
- Modified the token exchange logic to utilize the internal URL if provided, enhancing connectivity options for users.
2026-02-07 12:51:45 -06:00
Mauricio Siu
e4c440b265 Merge pull request #3627 from Dokploy/3583-dokploy-traefik-config-editor-does-not-allow-valid-traefik-config-to-be-saved
feat(traefik): add option to skip YAML validation for Go templating
2026-02-07 02:17:03 -06:00
Mauricio Siu
e39f0fee77 feat(traefik): add option to skip YAML validation for Go templating
- Introduced a checkbox to skip YAML validation in both the UpdateTraefikConfig and ShowTraefikFile components, allowing users to save configurations that utilize Go templating.
- Updated the onSubmit logic to conditionally validate YAML based on the new checkbox state, enhancing flexibility for users working with dynamic configurations.
2026-02-07 02:15:17 -06:00
Mauricio Siu
5b48e45536 Merge pull request #3626 from Dokploy/3535-container-creation-fails-after-vps-restart-bind-source-path-does-not-exist
feat(logs): display error messages for containers in dashboard logs
2026-02-07 02:11:55 -06:00
autofix-ci[bot]
3a7f76e33e [autofix.ci] apply automated fixes 2026-02-07 08:11:32 +00:00
Mauricio Siu
a54c84a138 feat(logs): display error messages for containers in dashboard logs
- Added error message display for containers in both the application and stack log views when using the "swarm" option.
- Updated Docker command to include error information for containers, enhancing visibility into container issues.
2026-02-07 02:10:55 -06:00
Mauricio Siu
8ba26f01e3 Merge pull request #3625 from Dokploy/2842-deployment-stuck-with-remote-server
2842 deployment stuck with remote server
2026-02-07 01:55:45 -06:00
Mauricio Siu
9bc88eba72 Merge branch 'canary' into 2842-deployment-stuck-with-remote-server 2026-02-07 01:54:09 -06:00
Mauricio Siu
b741618251 feat(dashboard): add clean all deployment queue action
- Introduced a new action in the dashboard to clean the entire deployment queue, enhancing user control over deployment processes.
- Implemented error handling with toast notifications to inform users of the success or failure of the action.
- Updated the API to support the new clean all deployment queue functionality.
2026-02-07 01:53:52 -06:00
Mauricio Siu
c0328ab63f Merge pull request #3623 from Dokploy/3506-deployment-queue-gets-stuck-when-application-is-deleted-while-deployment-task-is-processing
feat(dependencies): update bullmq and related packages to version 5.67.3
2026-02-07 01:48:07 -06:00
Mauricio Siu
425bcf8958 fix(schedules): ensure cronSchedule is always a string when removing jobs
- Updated the job removal logic to default cronSchedule to an empty string if job.pattern is undefined, preventing potential errors during job removal.
2026-02-07 01:42:14 -06:00
Mauricio Siu
26d4058457 feat(dependencies): update bullmq and related packages to version 5.67.3
- Upgraded bullmq to version 5.67.3 in both dokploy and schedules applications.
- Added new functions to retrieve jobs by application and compose IDs in the queue setup.
- Enhanced application and compose routers to cancel jobs based on user requests.
- Updated package dependencies for ioredis and msgpackr to their latest versions.
2026-02-07 01:35:03 -06:00
Horsley Lee
ccaac28f08 fix: avoid database connection during setup by using native exec
The setup.ts script imports execAsync from @dokploy/server, which triggers
the entire package to load including lib/auth.ts. This module initializes
betterAuth() with a database connection at import time, causing the setup
script to fail with ECONNREFUSED before the database container is created.

This fix replaces the import with Node.js native promisify(exec), avoiding
the module side-effect that attempts database connection during setup.
2026-02-07 15:29:43 +08:00
Mauricio Siu
a1a348e22d feat(dashboard): enhance Traefik actions with health check integration
- Added a new health check mutation for reloading Traefik, improving user feedback during the reload process.
- Updated button states to reflect the execution status of health checks, preventing user actions during ongoing operations.
- Refactored error handling for Traefik reload to provide clearer feedback on failures.
2026-02-07 00:53:32 -06:00
Mauricio Siu
ad29bb6ec2 feat(settings): improve background execution of Traefik setup with error logging
- Updated Traefik setup calls to run in the background, allowing immediate client response.
- Added error handling to log issues during the background execution of Traefik setup for better debugging.
2026-02-07 00:51:03 -06:00
Mauricio Siu
aa2e0e81c6 feat(settings): run Traefik setup in background to prevent proxy timeouts
- Modified the Traefik setup call to execute in the background, allowing immediate response to the client.
- Added error handling to log any issues during the background execution of the Traefik setup.
2026-02-07 00:34:43 -06:00
Mauricio Siu
3750cdab44 Merge pull request #3618 from Dokploy/feat/add-ui-to-show-unhealthy-status-logs
feat(logs): enhance container status display in logs
2026-02-07 00:13:44 -06:00
Mauricio Siu
6cf448ba80 feat(logs): enhance container status display in logs
- Added support for displaying "ready" state in badge color logic.
- Updated logs display to include container status and current state in the dashboard components.
- Modified Docker command outputs to include status and current state for better visibility of container health.
2026-02-07 00:13:12 -06:00
Mauricio Siu
3e64647d0d Merge pull request #3617 from Dokploy/fix/add-missing-field-created-invitation-table
feat(database): add created_at column to invitation table and update …
2026-02-06 23:48:41 -06:00
Mauricio Siu
dde00fc380 feat(database): add created_at column to invitation table and update schema
- Introduced a new column "created_at" with a default timestamp to the "invitation" table.
- Updated the account schema to reflect this change, ensuring consistency across the database structure.
- Added corresponding metadata in the snapshot and journal files for versioning.
2026-02-06 23:48:21 -06:00
Mauricio Siu
f4ad3dae35 Merge pull request #3557 from vtomasr5/traefik-3.6.7-everywhere
fix: use the same traefik version everywhere
2026-02-06 23:32:09 -06:00
Mauricio Siu
85a8ec8ba9 Merge pull request #3616 from Dokploy/fix/add-loader-and-toast-success-when-traefik-loss-connection
feat(health-check): implement health check hook for post-mutation val…
2026-02-06 23:30:10 -06:00
autofix-ci[bot]
c68525aa59 [autofix.ci] apply automated fixes 2026-02-07 05:25:34 +00:00
Mauricio Siu
91d6365275 feat(health-check): implement health check hook for post-mutation validation
- Introduced `useHealthCheckAfterMutation` hook to perform health checks after mutations, ensuring services are ready before proceeding.
- Updated `ShowTraefikActions`, `EditTraefikEnv`, and `ManageTraefikPorts` components to utilize the new hook, enhancing user feedback and error handling during updates.
- Improved loading states by incorporating health check execution status into button states.
2026-02-06 23:24:38 -06:00
Mauricio Siu
35a7445a09 Merge pull request #3614 from Dokploy/3562-domain-enabled-services-lose-connectivity-to-other-compose-services-missing-default-network
refactor(network-service): enhance network addition logic to include …
2026-02-06 22:59:26 -06:00
Mauricio Siu
4607b15a85 refactor(network-service): enhance network addition logic to include default network
- Updated the addDokployNetworkToService function to automatically include the "default" network when adding new networks.
- Modified test cases to reflect the new behavior, ensuring no duplicates of the default network are added.
- Improved handling of network addition for both arrays and objects.
2026-02-06 22:56:27 -06:00
Mauricio Siu
4eae1a5c14 Merge pull request #3608 from Dokploy/3532-dokploy-is-not-updating
refactor(settings): update dokploy image handling during service update
2026-02-06 21:43:22 -06:00
Mauricio Siu
5381b13813 refactor(settings): remove deprecated Docker image functions
- Eliminated the getDokployImage and pullLatestRelease functions to streamline the settings service.
- Updated the code to focus on dynamic image retrieval, enhancing clarity and maintainability.
2026-02-06 21:40:53 -06:00
Mauricio Siu
66ae8e1fff Merge branch 'canary' into 3532-dokploy-is-not-updating 2026-02-06 15:45:47 -06:00
Mauricio Siu
1aa05eaa8d chore(pnpm-lock): add resend package version 6.8.0 2026-02-06 00:47:33 -06:00
Mauricio Siu
4f13c25ca2 refactor(network-form): clean up DriverOptsEntries rendering logic
- Simplified the rendering of DriverOptsEntries in the network form by consolidating the mapping logic.
- Improved formatting of example driver options in the form description for better readability.
- Ensured consistent handling of adding and removing driver options within the form.
2026-02-06 00:19:18 -06:00
autofix-ci[bot]
83599cee37 [autofix.ci] apply automated fixes 2026-02-06 06:18:48 +00:00
Mauricio Siu
1c4e95d8e3 refactor(settings): update dokploy image handling during service update
- Removed the deprecated getDokployImage function and replaced it with dynamic image retrieval based on available updates.
- The service update now checks for available updates and uses the latest version from the update data, enhancing deployment efficiency.
2026-02-06 00:16:02 -06:00
Mauricio Siu
97f1105cf4 Merge pull request #3594 from Dokploy/copilot/add-network-configuration-tab
Add missing Network configuration to Swarm Settings
2026-02-05 23:18:54 -06:00
Mauricio Siu
c65026353a feat(network-form): add DriverOptsEntries to network form schema and UI
- Introduced DriverOptsEntries to the network form schema, allowing users to specify driver options for networks.
- Updated the form UI to support adding, editing, and removing driver options dynamically.
- Adjusted the backend schema to accept driver options as a record of key-value pairs.
2026-02-05 23:18:41 -06:00
Mauricio Siu
8872dc178c Merge branch 'canary' into copilot/add-network-configuration-tab 2026-02-05 23:08:54 -06:00
Mauricio Siu
fa0c2ec5e3 Merge pull request #3582 from bdkopen/update-hono
chore: update `hono` to resolve 9 CVEs
2026-02-05 23:07:40 -06:00
bdkopen
f9eda8e95d chore: update axios subdependency
Resolves https://github.com/advisories/GHSA-4hjh-wcwx-xvwj
2026-02-05 21:07:54 -05:00
bdkopen
5b2b0db686 chore: update jws subdependency
Resolves https://github.com/advisories/GHSA-869p-cjfg-cm3x
2026-02-05 21:07:53 -05:00
bdkopen
6576731842 chore: update form-data subdependency
Resolves https://github.com/advisories/GHSA-fjxv-7rqg-78g4
2026-02-05 21:07:52 -05:00
bdkopen
ec7bf9fd2f chore: update sha.js subdependency
Resolves https://github.com/browserify/sha.js/security/advisories/GHSA-95m3-7q98-8xr5
2026-02-05 21:07:46 -05:00
bdkopen
bc053744fc chore: update hono to resolve 9 CVEs
- (high) https://github.com/advisories/GHSA-m732-5p4w-x69g
- (high) https://github.com/advisories/GHSA-3vhc-576x-3qv4
- (high) https://github.com/advisories/GHSA-f67f-6cw9-8mq4
- (moderate) https://github.com/advisories/GHSA-92vj-g62v-jqhh
- (moderate) https://github.com/advisories/GHSA-q7jf-gf43-6x6p
- (moderate) https://github.com/advisories/GHSA-9r54-q6cx-xmh5
- (moderate) https://github.com/advisories/GHSA-w332-q679-j88p
- (moderate) https://github.com/advisories/GHSA-6wqw-2p9w-4vw4
- (moderate) https://github.com/advisories/GHSA-r354-f388-2fhh
2026-02-05 21:04:47 -05:00
Mauricio Siu
af87614cb0 Merge pull request #3606 from Dokploy/3403-volume-backup-restart-fails-for-private-registry-images-missing---with-registry-auth
refactor(backup): replace docker service scale with update command
2026-02-05 15:45:50 -06:00
Mauricio Siu
b2484da2af refactor(backup): replace docker service scale with update command
- Updated the backupVolume function to use `docker service update --replicas` instead of `docker service scale` for stopping and starting application replicas.
- This change enhances the backup process by ensuring proper handling of service updates with registry authentication.
2026-02-05 15:45:18 -06:00
Mauricio Siu
5d3c05d291 Update package.json 2026-02-05 15:14:31 -06:00
Mauricio Siu
40accfbf60 Merge pull request #3603 from Dokploy/3581-duplicating-a-database-fails-with-unique-constraint-violation-backup_appname_unique
fix(project): include appName in backup processing
2026-02-05 15:03:53 -06:00
Mauricio Siu
3f0558d077 fix(project): include appName in backup processing
- Updated backup processing logic to destructure appName from backup objects, ensuring it is available for further operations.
- This change is applied consistently across multiple sections of the project router.
2026-02-05 15:03:22 -06:00
Mauricio Siu
7ae3d7d906 Merge pull request #3512 from mhbdev/resend-provider-for-notifications
feat: add resend notification functionality
2026-02-05 15:01:10 -06:00
Mauricio Siu
6877ebe027 feat(database): add resend notification type and related table
- Introduced a new notification type 'resend' to the database.
- Created a new table 'resend' with fields for managing resend notifications.
- Updated the 'notification' table to include a foreign key reference to 'resendId'.
- Added corresponding snapshot and journal entries to reflect these changes.
2026-02-05 15:00:10 -06:00
Mauricio Siu
51e881d831 feat(database): add pushover notification type and related table
- Introduced a new notification type 'pushover' to the database.
- Created a new table 'pushover' with relevant fields for notification management.
- Updated the 'notification' table to include a foreign key reference to 'pushoverId' for enhanced notification handling.
- Added corresponding snapshot and journal entries to reflect these changes.
2026-02-05 14:54:23 -06:00
Mauricio Siu
80bbb752b6 chore: remove obsolete SQL file and associated snapshot
- Deleted the '0137_worried_shriek.sql' file as it is no longer needed.
- Updated the journal to remove references to the deleted SQL file.
- Removed the corresponding snapshot JSON file to maintain a tidy project structure.
2026-02-05 14:53:23 -06:00
Mauricio Siu
3c9945ec35 Revert "chore: remove obsolete SQL files and associated snapshots"
This reverts commit d77c562c84.

# Conflicts:
#	apps/dokploy/drizzle/meta/0137_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2026-02-05 14:49:01 -06:00
Mauricio Siu
4d8c358b33 chore: update pnpm-lock.yaml with new package resolutions
- Added fast-sha256@1.3.0 and uuid@10.0.0 with their respective integrity resolutions.
- Updated snapshots to include the new package entries.
2026-02-05 14:42:25 -06:00
Mauricio Siu
4b82659a48 Merge branch 'canary' into resend-provider-for-notifications 2026-02-05 14:42:14 -06:00
Mauricio Siu
d77c562c84 chore: remove obsolete SQL files and associated snapshots
- Deleted SQL files '0136_tidy_puff_adder.sql' and '0137_worried_shriek.sql' as they are no longer needed.
- Updated the journal and removed references to the deleted SQL files.
- Cleaned up the corresponding snapshot JSON files to maintain a tidy project structure.
2026-02-05 14:41:33 -06:00
Mauricio Siu
37ea75be3e chore(readme): remove sponsor section and streamline content
- Eliminated the sponsors section and related acknowledgments from the README.md to simplify the document.
- Updated the layout to focus on the core features and purpose of Dokploy.
2026-02-05 09:51:53 -06:00
Mauricio Siu
82158ed34d feat(auth): introduce BETTER_AUTH_SECRET for better authentication handling
- Added BETTER_AUTH_SECRET constant to manage authentication secret, defaulting to a predefined value if not set in the environment.
- Updated betterAuth configuration to utilize BETTER_AUTH_SECRET for enhanced security in authentication processes.
2026-02-05 09:46:30 -06:00
Mauricio Siu
9ab98c9a63 Merge branch 'canary' into copilot/add-network-configuration-tab 2026-02-05 02:31:51 -06:00
Mauricio Siu
ca7d3f8cb3 Merge pull request #3599 from Dokploy/copilot/fix-postgres-deployment-port-conflict
fix: add port conflict validation for database external ports
2026-02-05 02:31:20 -06:00
Mauricio Siu
66448ff6c2 fix: improve error handling for external port updates in database credential components
- Updated error handling in the ShowExternal*Credentials components for MariaDB, MongoDB, MySQL, PostgreSQL, and Redis to display specific error messages when saving the external port fails.
2026-02-05 02:31:02 -06:00
Mauricio Siu
31d47efb1e Merge branch 'canary' into copilot/fix-postgres-deployment-port-conflict 2026-02-05 02:28:18 -06:00
Mauricio Siu
33802f554a chore(dokploy): update build-next script to use webpack for improved performance 2026-02-05 02:27:09 -06:00
Mauricio Siu
38265fd921 Merge pull request #3600 from Dokploy/feat/introduce-license-key-pay
Feat/introduce license key pay
2026-02-05 02:19:31 -06:00
Mauricio Siu
ca2efc5c68 fix(enterprise): update LICENSE_KEY_URL for production environment
- Changed the LICENSE_KEY_URL from "https://api-license-key.dokploy.com" to "https://licenses.dokploy.com" to reflect the correct production endpoint.
2026-02-05 02:18:24 -06:00
Mauricio Siu
dfcb422294 refactor(enterprise): consolidate LICENSE_KEY_URL handling and improve license validation logic
- Moved LICENSE_KEY_URL definition to a centralized location for better maintainability.
- Updated license validation function to utilize the new LICENSE_KEY_URL import, enhancing clarity and consistency in API calls.
2026-02-05 02:15:59 -06:00
autofix-ci[bot]
47470e2343 [autofix.ci] apply automated fixes 2026-02-05 08:05:36 +00:00
Mauricio Siu
bac9dd5c31 chore(deps): update @types/node version across multiple packages
- Upgraded @types/node from version ^18.19.104 to ^20.16.0 in package.json files for apps/api, apps/dokploy, apps/schedules, and packages/server.
- Adjusted pnpm-lock.yaml to reflect the updated @types/node version across all relevant dependencies.
- Added a new setup file for mock database interactions in the dokploy app to enhance testing capabilities.
2026-02-05 02:05:07 -06:00
Mauricio Siu
65dab84e7f chore(deps): upgrade drizzle-orm and better-auth utils versions in package.json and pnpm-lock.yaml
- Updated drizzle-orm from version ^0.39.3 to ^0.41.0 for enhanced performance and features.
- Upgraded @better-auth/utils from version 0.2.4 to 0.3.0 to incorporate the latest improvements.
- Adjusted pnpm-lock.yaml to reflect these dependency updates.
2026-02-05 01:55:58 -06:00
autofix-ci[bot]
3a0da19ea8 [autofix.ci] apply automated fixes 2026-02-05 07:54:52 +00:00
Mauricio Siu
5e460e6b4f chore(deps): update drizzle-orm and drizzle-kit versions in package.json and pnpm-lock.yaml
- Upgraded drizzle-orm from version ^0.39.3 to ^0.41.0 for improved functionality and performance.
- Updated drizzle-kit from version ^0.30.6 to ^0.31.4 to ensure compatibility with the latest drizzle-orm version.
- Adjusted related dependencies in pnpm-lock.yaml to reflect these changes.
2026-02-05 01:54:24 -06:00
Mauricio Siu
9299f04f74 chore(deps): update Vitest version and related dependencies in pnpm-lock.yaml and package.json
- Upgraded Vitest from version 1.6.1 to 4.0.18 to leverage new features and improvements.
- Updated dependency versions in pnpm-lock.yaml to ensure compatibility with the latest Vitest version.
2026-02-05 01:45:44 -06:00
Mauricio Siu
2746133252 delete(tests): remove mock database setup file and update Vitest configuration
- Deleted the mock database setup file to streamline the test environment.
- Updated the Vitest configuration to remove the reference to the deleted setup file, enhancing clarity in test setup.
2026-02-05 01:17:04 -06:00
Mauricio Siu
bde192c1e7 feat(admin): handle empty member list in trusted origins retrieval
- Added a check to return an empty array if no members are found, improving the robustness of the `getTrustedOrigins` function.
2026-02-05 01:13:25 -06:00
Mauricio Siu
99646f887b feat(tests): enhance mock database with member methods for testing
- Added mock implementations for `member.findFirst` and `member.findMany` methods in the mock database setup.
- This enhancement improves the test environment by allowing more comprehensive simulation of member-related database interactions.
2026-02-05 00:59:07 -06:00
Mauricio Siu
542ccc4479 feat(sso): enhance SSO provider management and trusted origins handling
- Added logic to retrieve and delete SSO providers, ensuring proper permission checks and error handling.
- Updated user trusted origins when adding or removing SSO providers, maintaining accurate origin lists.
- Refactored trusted origins retrieval to improve clarity and efficiency in the authentication process.
- Introduced utility functions for normalizing trusted origins and converting request headers.
2026-02-05 00:55:17 -06:00
Mauricio Siu
9910c0e602 feat(db): add sso_provider table and update user schema
- Created a new table `sso_provider` with relevant fields and constraints.
- Added new columns to the `user` table: `enableEnterpriseFeatures`, `licenseKey`, `isValidEnterpriseLicense`, and `trustedOrigins`.
- Established foreign key relationships for `user_id` and `organization_id` in the `sso_provider` table.
2026-02-05 00:50:12 -06:00
Mauricio Siu
4f0d707905 delete: remove obsolete SQL migration files and snapshots
- Deleted SQL migration files for `0137_naive_power_pack`, `0138_common_mathemanic`, `0139_smiling_havok`, and `0140_great_lightspeed` as they are no longer needed.
- Removed corresponding snapshot files to maintain consistency in the database schema history.
2026-02-05 00:48:44 -06:00
copilot-swe-agent[bot]
a86fe46b7b Fix variable naming in database routers
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-05 05:54:23 +00:00
copilot-swe-agent[bot]
139c06b63d Add port validation to database external ports
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-05 05:52:38 +00:00
copilot-swe-agent[bot]
999dc7d360 Initial plan 2026-02-05 05:47:52 +00:00
Mauricio Siu
dc74d3057a fix(tests): update setup file path in Vitest configuration for clarity
- Changed the setup file path for global mocks in the Vitest configuration to a more explicit location, improving clarity and organization of test setup.
2026-02-04 23:36:00 -06:00
Mauricio Siu
ac833ef265 feat(tests): enhance Vitest configuration with additional environment variables and updated setup path
- Updated the setup file path for global mocks in the Vitest configuration to improve clarity.
- Added environment variables for GitHub and Google credentials to the test environment, facilitating integration testing.
2026-02-04 23:34:20 -06:00
Mauricio Siu
00e31f399e fix(db): update deprecation warning for legacy database credentials
- Added a condition to display the deprecation warning for legacy database credentials only in non-test environments.
- This change prevents unnecessary warnings during testing, improving the developer experience.
2026-02-04 23:33:17 -06:00
Mauricio Siu
8001e5d24a feat(tests): add values method to mock database for enhanced testing
- Introduced a mock implementation for the `values` method in the mock database setup.
- This addition improves the test environment by allowing more comprehensive simulation of database interactions.
2026-02-04 23:33:10 -06:00
Mauricio Siu
cfb9534e06 feat(tests): enhance mock database with web server settings for testing
- Added mock implementations for `webServerSettings` to support the `getWebServerSettings` function in tests.
- This update improves the test environment by simulating necessary database interactions for web server settings.
2026-02-04 23:28:51 -06:00
copilot-swe-agent[bot]
c2894260dc Revert to consistent pattern with existing swarm forms
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-04 15:23:29 +00:00
copilot-swe-agent[bot]
582f493f3f Fix type safety and optimize mutation payload in network form
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-04 15:22:26 +00:00
Mauricio Siu
8335f40238 feat(tests): add mock database setup for Vitest testing environment
- Introduced a new mock database setup file to simulate database interactions during tests.
- Updated Vitest configuration to include the mock setup file, enhancing test reliability and isolation.
2026-02-04 09:20:19 -06:00
copilot-swe-agent[bot]
f6f0921560 Add network configuration form to Swarm Settings
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-04 15:20:03 +00:00
copilot-swe-agent[bot]
c739c67616 Initial plan 2026-02-04 15:16:49 +00:00
Mauricio Siu
dc8148ae51 fix(db): update database URL configuration for production and development environments
- Modified the database URL assignment logic to differentiate between production and development environments.
- Ensured that the correct database URL is used based on the NODE_ENV variable, improving deployment flexibility.
2026-02-04 08:58:10 -06:00
Mauricio Siu
3307f62183 refactor(auth): remove unused SSO provider retrieval logic
- Deleted the import statement for `getSSOProviders` and the associated logic for fetching issuer origins from SSO providers.
- This cleanup improves code clarity by removing unnecessary dependencies and streamlining the trusted origins configuration.
2026-02-04 08:52:09 -06:00
Mauricio Siu
2b36381f8d fix: update import path for getPublicIpWithFallback in enterprise utility 2026-02-04 08:51:18 -06:00
Mauricio Siu
945406adc5 Merge branch 'canary' into feat/introduce-license-key-pay 2026-02-02 10:59:06 -06:00
Mauricio Siu
1e7522d173 Merge pull request #3580 from Bima42/fix/mistral-modal-require-bearer
fix: double bearer for mistral provider
2026-02-02 10:30:15 -06:00
Bima42
2b72b4888c fix: avoid enforce bearer for mistral in input 2026-02-02 11:45:45 +01:00
Mauricio Siu
8b1cc949c0 feat(sso): implement SSO provider retrieval functionality
- Added a new service to fetch SSO providers from the database, including relevant fields such as id, providerId, issuer, domain, oidcConfig, and samlConfig.
- This functionality will support future enhancements in SSO integration.
2026-02-01 22:44:08 -06:00
Mauricio Siu
a70018f70a feat(auth): add enterprise feature flags to user context and request validation
- Updated user context to include `enableEnterpriseFeatures` and `isValidEnterpriseLicense` properties.
- Modified request validation to set these properties based on user data, enhancing enterprise feature management.
- Adjusted the enterprise procedure to check user flags directly from the context instead of querying the database.
2026-02-01 22:01:13 -06:00
Mauricio Siu
71b87895eb refactor(auth): streamline trusted origins configuration and improve readability
- Changed the export of the `handler` and `api` constants to local scope for better clarity.
- Enhanced the trusted origins logic by restructuring the code for improved readability and maintainability.
- Commented out the cloud environment redirection logic for future consideration, aligning with previous changes in the codebase.
2026-02-01 21:38:07 -06:00
Mauricio Siu
354407cd12 chore(license): comment out cloud environment redirection logic in license settings page for future consideration 2026-02-01 20:11:19 -06:00
Mauricio Siu
766fd00be5 Merge branch 'canary' into feat/introduce-license-key-pay 2026-02-01 19:59:28 -06:00
Mauricio Siu
c31e970172 Merge pull request #3573 from bdkopen/update-next
chore: update Next to resolve 3 CVEs
2026-02-01 19:57:05 -06:00
Mauricio Siu
c56def9c97 fix(db): update database URL for Docker compatibility
- Commented out the old database URL for security reasons.
- Updated the database connection string to use the Docker service name for PostgreSQL, ensuring proper connectivity in containerized environments.
2026-02-01 19:54:43 -06:00
Mauricio Siu
aa558b3a8c feat(sso): update SAML registration dialog and settings for improved metadata handling
- Added support for IdP metadata XML in the SAML registration dialog, allowing users to paste full metadata for configuration.
- Updated the callback URL and audience handling to dynamically incorporate the base URL.
- Refactored the SSO settings to enable SAML provider registration and improved the display of callback URLs based on provider details.
- Enhanced trusted origins configuration in the authentication logic to include additional domains for development and production environments.
2026-02-01 19:50:33 -06:00
Mauricio Siu
11082f25d7 feat(sso): enhance OIDC registration mapping for Azure and other providers
- Updated the mapping logic in `register-oidc-dialog` to differentiate between Azure and other identity providers.
- Simplified the mapping structure for user attributes based on the issuer, improving flexibility in handling various OIDC providers.
2026-02-01 00:35:42 -06:00
Mauricio Siu
00ce8cad1b feat(license): enhance license key management and authorization checks
- Added authorization checks to ensure only users with the "owner" role can activate or deactivate license keys.
- Updated the menu item visibility logic to simplify role checks for admin and owner users.
- Commented out the cloud environment redirection logic in the license settings page for future consideration.
2026-01-31 18:03:03 -06:00
Mauricio Siu
dc756e2bbb refactor(auth): rename forgetPassword to requestPasswordReset for clarity
- Updated the method name from `forgetPassword` to `requestPasswordReset` in the password reset flow to better reflect its functionality.
2026-01-31 17:07:06 -06:00
Mauricio Siu
fb06cf8e55 feat(auth): add Okta domain to SSO provider list and adjust SSO request handling
- Included a new Okta domain in the array of allowed domains for SSO authentication.
- Modified the SSO request handling logic to return early if the request is an SSO callback, enhancing the flow of authentication.
2026-01-31 15:28:15 -06:00
Mauricio Siu
69ba901535 feat(sso): update SSO provider registration to handle multiple domains
- Refactored `register-oidc-dialog` and `register-saml-dialog` to accept an array of domains instead of a single domain string.
- Enhanced server-side validation to check for duplicate domains across registered providers.
- Updated SSO schema to reflect the change from a single domain to an array of domains, including validation for domain format.
2026-01-31 13:17:24 -06:00
bdkopen
4667cb525f chore: update next 2026-01-31 10:04:53 -05:00
Vicens Juan Tomas Monserrat
21b1652259 fix: Use the same traefik version everywhere 2026-01-30 13:53:44 +01:00
Mauricio Siu
25d37b76a1 refactor: update swarm forms exports and remove unused SQL files
- Updated the exports in the swarm forms index to include LabelsForm and ModeForm while ensuring RestartPolicyForm and UpdateConfigForm are correctly exported.
- Removed the obsolete SQL file '0135_illegal_magik.sql' and its references from the journal.
- Deleted the associated snapshot JSON file to clean up unused database schema definitions.
2026-01-28 09:43:54 -06:00
autofix-ci[bot]
ad382f1fe5 [autofix.ci] apply automated fixes 2026-01-28 10:38:52 +00:00
mhbdev
d5b0e3193a Merge branch 'resend-provider-for-notifications' of https://github.com/mhbdev/dokploy into resend-provider-for-notifications 2026-01-28 14:08:21 +03:30
autofix-ci[bot]
43228fc51b [autofix.ci] apply automated fixes 2026-01-28 13:54:40 +03:30
mhbdev
ba8a334fbe feat: add resend notification functionality
- Introduced a new notification type "resend" to the system.
- Added database schema for resend notifications including fields for apiKey, fromAddress, and toAddress.
- Implemented functions to create, update, and send resend notifications.
- Updated notification router to handle resend notifications with appropriate API endpoints.
- Enhanced existing notification services to support sending notifications via the Resend service.
- Modified various notification utilities to accommodate the new resend functionality.
2026-01-28 13:47:57 +03:30
autofix-ci[bot]
6271f3bb1a [autofix.ci] apply automated fixes 2026-01-24 18:15:23 +00:00
mhbdev
57eee45dbb feat: add resend notification functionality
- Introduced a new notification type "resend" to the system.
- Added database schema for resend notifications including fields for apiKey, fromAddress, and toAddress.
- Implemented functions to create, update, and send resend notifications.
- Updated notification router to handle resend notifications with appropriate API endpoints.
- Enhanced existing notification services to support sending notifications via the Resend service.
- Modified various notification utilities to accommodate the new resend functionality.
2026-01-24 21:29:19 +03:30
134 changed files with 11331 additions and 4533 deletions

View File

@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
- [ ] You created a dedicated branch based on the `canary` branch.
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [ ] You have tested this PR in your local instance.
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
## Issues related (if applicable)

View File

@@ -2,7 +2,7 @@
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
We have a few guidelines to follow when contributing to this project:
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
- [Development](#development)
- [Build](#build)
- [Pull Request](#pull-request)
- [Important Considerations](#important-considerations-for-pull-requests)
## Commit Convention
@@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
- If your pull request fixes an open issue, please reference the issue in the pull request description.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
**Important Considerations for Pull Requests:**
### Important Considerations for Pull Requests
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).

View File

@@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]

View File

@@ -12,24 +12,8 @@
<br />
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<br>
<a href="https://tuple.app/dokploy">
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
</a>
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
</div>
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
## ✨ Features
Dokploy includes multiple features to make your life easier.
@@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
## ♥️ Sponsors
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
[Dokploy Open Collective](https://opencollective.com/dokploy)
[Github Sponsors](https://github.com/sponsors/Siumauricio)
## Sponsors
| Sponsor | Logo | Supporter Level |
|---------|:----:|----------------|
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
### Community Backers 🤝
#### Organizations:
[Sponsors on Open Collective](https://opencollective.com/dokploy)
#### Individuals:
[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy)
### Contributors 🤝
<a href="https://github.com/dokploy/dokploy/graphs/contributors">

View File

@@ -14,7 +14,7 @@
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"hono": "^4.11.7",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "18.2.0",
@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.17.51",
"@types/node": "^20.16.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.16.2",

View File

@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
describe("addDokployNetworkToService", () => {
it("should add network to an empty array", () => {
const result = addDokployNetworkToService([]);
expect(result).toEqual(["dokploy-network"]);
expect(result).toEqual(["dokploy-network", "default"]);
});
it("should not add duplicate network to an array", () => {
const result = addDokployNetworkToService(["dokploy-network"]);
expect(result).toEqual(["dokploy-network"]);
expect(result).toEqual(["dokploy-network", "default"]);
});
it("should add network to an existing array with other networks", () => {
const result = addDokployNetworkToService(["other-network"]);
expect(result).toEqual(["other-network", "dokploy-network"]);
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
});
it("should add network to an object if networks is an object", () => {
const result = addDokployNetworkToService({ "other-network": {} });
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
expect(result).toEqual({
"other-network": {},
"dokploy-network": {},
default: {},
});
});
it("should not duplicate default network when already present", () => {
const result = addDokployNetworkToService(["default", "dokploy-network"]);
expect(result).toEqual(["default", "dokploy-network"]);
});
});

View File

@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<[], Promise<never>>();
const inspect = vi.fn<() => Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
async () => undefined,
);
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(async () => undefined);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
@@ -80,7 +80,9 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}
@@ -97,7 +99,9 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}

View File

@@ -0,0 +1,40 @@
import { vi } from "vitest";
/**
* Mock the DB module so tests that import from @dokploy/server (barrel)
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
* Without this, loading the server barrel pulls in lib/auth and db, which
* connect to localhost:5432 and cause ECONNREFUSED.
*/
vi.mock("@dokploy/server/db", () => {
const chain = () => chain;
chain.set = () => chain;
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
chain.then = undefined;
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
findMany: vi.fn(() => Promise.resolve([])),
insert: vi.fn(() => Promise.resolve([{}])),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
const createQueryMock = () => tableMock;
return {
db: {
select: vi.fn(() => chain),
insert: vi.fn(() => ({
values: () => ({ returning: () => Promise.resolve([{}]) }),
})),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
query: new Proxy({} as Record<string, typeof tableMock>, {
get: () => tableMock,
}),
},
dbUrl: "postgres://mock:mock@localhost:5432/mock",
};
});

View File

@@ -7,10 +7,15 @@ export default defineConfig({
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
pool: "forks",
setupFiles: [path.resolve(__dirname, "setup.ts")],
},
define: {
"process.env": {
NODE: "test",
GITHUB_CLIENT_ID: "test",
GITHUB_CLIENT_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
},
},
plugins: [

View File

@@ -22,6 +22,7 @@ import {
HealthCheckForm,
LabelsForm,
ModeForm,
NetworkForm,
PlacementForm,
RestartPolicyForm,
RollbackConfigForm,
@@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [
docDescription:
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
},
{
id: "network",
label: "Network",
description: "Configure network attachments",
docDescription:
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
},
{
id: "labels",
label: "Labels",
@@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
<RollbackConfigForm id={id} type={type} />
)}
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
{activeMenu === "stop-grace-period" && (
<StopGracePeriodForm id={id} type={type} />

View File

@@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form";
export { HealthCheckForm } from "./health-check-form";
export { LabelsForm } from "./labels-form";
export { ModeForm } from "./mode-form";
export { NetworkForm } from "./network-form";
export { PlacementForm } from "./placement-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";

View File

@@ -0,0 +1,313 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const driverOptEntrySchema = z.object({
key: z.string(),
value: z.string(),
});
export const networkFormSchema = z.object({
networks: z
.array(
z.object({
Target: z.string().optional(),
Aliases: z.string().optional(),
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
}),
)
.optional(),
});
interface NetworkFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<z.infer<typeof networkFormSchema>>({
resolver: zodResolver(networkFormSchema),
defaultValues: {
networks: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "networks",
});
useEffect(() => {
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
const networkEntries = data.networkSwarm.map((network) => ({
Target: network.Target || "",
Aliases: network.Aliases?.join(", ") || "",
DriverOptsEntries: network.DriverOpts
? Object.entries(network.DriverOpts).map(([key, value]) => ({
key,
value: value ?? "",
}))
: [],
}));
form.reset({ networks: networkEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
setIsLoading(true);
try {
const networksArray =
formData.networks
?.filter((network) => network.Target)
.map((network) => {
const entries = (network.DriverOptsEntries ?? []).filter(
(e) => e.key.trim() !== "",
);
const driverOpts =
entries.length > 0
? Object.fromEntries(
entries.map((e) => [e.key.trim(), e.value]),
)
: undefined;
return {
Target: network.Target,
Aliases: network.Aliases
? network.Aliases.split(",").map((alias) => alias.trim())
: undefined,
DriverOpts: driverOpts,
};
}) || [];
// If no networks, send null to clear the database
const networksToSend = networksArray.length > 0 ? networksArray : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
networkSwarm: networksToSend,
});
toast.success("Network configuration updated successfully");
refetch();
} catch {
toast.error("Error updating network configuration");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Networks</FormLabel>
<FormDescription>
Configure network attachments for your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2 p-3 border rounded">
<FormField
control={form.control}
name={`networks.${index}.Target`}
render={({ field }) => (
<FormItem>
<FormLabel>Network Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-network" />
</FormControl>
<FormDescription>
The name of the network to attach to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.Aliases`}
render={({ field }) => (
<FormItem>
<FormLabel>Aliases (optional)</FormLabel>
<FormControl>
<Input
{...field}
placeholder="alias1, alias2, alias3"
/>
</FormControl>
<FormDescription>
Comma-separated list of network aliases
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Driver options (optional)</FormLabel>
<FormDescription>
e.g. com.docker.network.driver.mtu,
com.docker.network.driver.host_binding
</FormDescription>
{(
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
).map((_, optIndex) => (
<div
key={optIndex}
className="flex gap-2 items-end flex-wrap"
>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[140px]">
<FormControl>
<Input
{...field}
placeholder="com.docker.network.driver.mtu"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[100px]">
<FormControl>
<Input {...field} placeholder="1500" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const entries =
form.getValues(
`networks.${index}.DriverOptsEntries`,
) ?? [];
form.setValue(
`networks.${index}.DriverOptsEntries`,
entries.filter((_, i) => i !== optIndex),
);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const entries =
form.getValues(`networks.${index}.DriverOptsEntries`) ??
[];
form.setValue(`networks.${index}.DriverOptsEntries`, [
...entries,
{ key: "", value: "" },
]);
}}
>
Add driver option
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove Network
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
Target: "",
Aliases: "",
DriverOptsEntries: [],
})
}
>
Add Network
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ networks: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Networks
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -24,6 +24,8 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const UpdateTraefikConfigSchema = z.object({
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery(
{
applicationId,
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}, [data]);
const onSubmit = async (data: UpdateTraefikConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
});
return;
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
});
return;
}
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
setOpen(open);
if (!open) {
form.reset();
setSkipYamlValidation(false);
}
}}
>
@@ -169,7 +175,28 @@ routers:
</div>
</form>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-4">
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
<div className="flex items-center space-x-2">
<Checkbox
id="skip-yaml-validation-app"
checked={skipYamlValidation}
onCheckedChange={(checked) =>
setSkipYamlValidation(checked === true)
}
/>
<Label
htmlFor="skip-yaml-validation-app"
className="text-sm font-normal cursor-pointer"
>
Skip YAML validation (for Go templating)
</Label>
</div>
<p className="text-sm text-muted-foreground">
Check to save configs with Go templating (e.g.{" "}
<code className="text-xs">{"{{range}}"}</code>).
</p>
</div>
<Button
isLoading={isLoading}
form="hook-form-update-traefik-config"

View File

@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
case "ready":
return "green";
case "exited":
case "shutdown":
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectGroup>
</SelectContent>
</Select>
{option === "swarm" &&
services?.find((c) => c.containerId === containerId)?.error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
<span className="font-medium">Error: </span>
{services?.find((c) => c.containerId === containerId)?.error}
</div>
)}
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}

View File

@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</SelectGroup>
</SelectContent>
</Select>
{option === "swarm" &&
services?.find((c) => c.containerId === containerId)?.error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
<span className="font-medium">Error: </span>
{services.find((c) => c.containerId === containerId)?.error}
</div>
)}
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import {
Card,
CardContent,
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -16,6 +17,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
},
);
const [canEdit, setCanEdit] = useState(true);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { mutateAsync, isLoading, error, isError } =
api.settings.updateTraefikFile.useMutation();
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -153,14 +158,37 @@ routers:
/>
)}
</div>
<div className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update
</Button>
<div className="flex flex-col gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="skip-yaml-validation"
checked={skipYamlValidation}
onCheckedChange={(checked) =>
setSkipYamlValidation(checked === true)
}
/>
<Label
htmlFor="skip-yaml-validation"
className="text-sm font-normal cursor-pointer"
>
Skip YAML validation (for Go templating)
</Label>
</div>
<p className="text-sm text-muted-foreground -mt-2">
Traefik supports Go templating in dynamic configs (e.g.{" "}
<code className="text-xs">{"{{range}}"}</code>). Configs using
templates will fail standard YAML validation. Check this to save
without validation.
</p>
<div className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update
</Button>
</div>
</div>
</form>
</Form>

View File

@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -21,6 +21,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -39,6 +40,10 @@ const Schema = z.object({
giteaUrl: z.string().min(1, {
message: "Gitea URL is required",
}),
giteaInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
clientId: z.string().min(1, {
message: "Client ID is required",
}),
@@ -70,6 +75,7 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
});
}, [form, webhookUrl, isOpen]);
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
giteaInternalUrl: data.giteaInternalUrl || undefined,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
)}
/>
<FormField
control={form.control}
name="giteaInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitea:3000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when Gitea runs on the same instance as Dokploy.
Used for OAuth token exchange to reach Gitea via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -19,6 +19,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -30,6 +31,10 @@ import { useUrl } from "@/utils/hooks/use-url";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
giteaUrl: z.string().min(1, "Gitea URL is required"),
giteaInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
});
@@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
clientId: "",
clientSecret: "",
},
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
form.reset({
name: gitea.gitProvider?.name || "",
giteaUrl: gitea.giteaUrl || "https://gitea.com",
giteaInternalUrl: gitea.giteaInternalUrl || "",
clientId: gitea.clientId || "",
clientSecret: gitea.clientSecret || "",
});
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
name: values.name,
giteaUrl: values.giteaUrl,
giteaInternalUrl: values.giteaInternalUrl ?? null,
clientId: values.clientId,
clientSecret: values.clientSecret,
})
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitea:3000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when Gitea runs on the same instance as Dokploy. Used
for OAuth token exchange to reach Gitea via internal network
(e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"

View File

@@ -21,6 +21,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -35,6 +36,10 @@ const Schema = z.object({
gitlabUrl: z.string().min(1, {
message: "GitLab URL is required",
}),
gitlabInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
applicationId: z.string().min(1, {
message: "Application ID is required",
}),
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
});
}, [form, isOpen]);
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
name: data.name || "",
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
)}
/>
<FormField
control={form.control}
name="gitlabInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitlab:80"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when GitLab runs on the same instance as Dokploy.
Used for OAuth token exchange to reach GitLab via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -20,6 +20,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -33,6 +34,10 @@ const Schema = z.object({
gitlabUrl: z.string().url({
message: "Invalid Gitlab URL",
}),
gitlabInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
groupName: z.string().optional(),
});
@@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
});
}, [form, isOpen]);
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: data.groupName || "",
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
)}
/>
<FormField
control={form.control}
name="gitlabInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitlab:80"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when GitLab runs on the same instance as Dokploy.
Used for OAuth token exchange to reach GitLab via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="groupName"

View File

@@ -16,6 +16,7 @@ import {
LarkIcon,
NtfyIcon,
PushoverIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -97,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("resend"),
apiKey: z.string().min(1, { message: "API Key is required" }),
fromAddress: z
.string()
.min(1, { message: "From Address is required" })
.email({ message: "Email is invalid" }),
toAddresses: z
.array(
z.string().min(1, { message: "Email is required" }).email({
message: "Email is invalid",
}),
)
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
@@ -169,6 +187,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
resend: {
icon: <ResendIcon className="text-muted-foreground" />,
label: "Resend",
},
gotify: {
icon: <GotifyIcon />,
label: "Gotify",
@@ -214,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
api.notification.testResendConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
@@ -242,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const resendMutation = notificationId
? api.notification.updateResend.useMutation()
: api.notification.createResend.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
@@ -281,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
if ((type === "email" || type === "resend") && fields.length === 0) {
append("");
}
}, [type, append, fields.length]);
@@ -349,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "resend") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
apiKey: notification.resend?.apiKey,
toAddresses: notification.resend?.toAddresses,
fromAddress: notification.resend?.fromAddress,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
@@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
@@ -525,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
emailId: notification?.emailId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "resend") {
promise = resendMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
resendId: notification?.resendId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
@@ -1042,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</>
)}
{type === "resend" && (
<>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="re_********"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input placeholder="from@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2 pt-2">
<FormLabel>To Addresses</FormLabel>
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-row gap-2 w-full"
>
<FormField
control={form.control}
name={`toAddresses.${index}`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
placeholder="email@example.com"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
type="button"
onClick={() => {
remove(index);
}}
>
Remove
</Button>
</div>
))}
{type === "resend" &&
"toAddresses" in form.formState.errors && (
<div className="text-sm font-medium text-destructive">
{form.formState?.errors?.toAddresses?.root?.message}
</div>
)}
</div>
<Button
variant="outline"
type="button"
onClick={() => {
append("");
}}
>
Add
</Button>
</>
)}
{type === "gotify" && (
<>
<FormField
@@ -1627,6 +1776,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
@@ -1667,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "resend") {
await testResendConnection({
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "gotify") {
await testGotifyConnection({
serverUrl: data.serverUrl,

View File

@@ -5,6 +5,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Lark.
Telegram, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "resend" && (
<div className="flex items-center justify-center rounded-lg ">
<ResendIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<GotifyIcon className="size-6" />

View File

@@ -23,6 +23,8 @@ export const ShowDokployActions = () => {
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
const { mutateAsync: cleanAllDeploymentQueue } =
api.settings.cleanAllDeploymentQueue.useMutation();
return (
<DropdownMenu>
@@ -87,6 +89,21 @@ export const ShowDokployActions = () => {
Clean Redis
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await cleanAllDeploymentQueue()
.then(() => {
toast.success("Deployment queue cleaned");
})
.catch(() => {
toast.error("Error cleaning deployment queue");
});
}}
>
Clean all deployment queue
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
@@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
serverId,
});
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
pollInterval: 4000,
successMessage: "Traefik dashboard updated successfully",
onSuccess: () => {
refetchDashboard();
},
});
const {
execute: executeReloadWithHealthCheck,
isExecuting: isReloadHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
pollInterval: 4000,
successMessage: "Traefik Reloaded",
});
return (
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
disabled={
reloadTraefikIsLoading ||
toggleDashboardIsLoading ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
>
<Button
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
isLoading={
reloadTraefikIsLoading ||
toggleDashboardIsLoading ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
@@ -54,15 +86,19 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<DropdownMenuGroup>
<DropdownMenuItem
onClick={async () => {
await reloadTraefik({
serverId: serverId,
})
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {});
try {
await executeReloadWithHealthCheck(() =>
reloadTraefik({ serverId }),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
"Failed to reload Traefik. Please try again.";
toast.error(errorMessage);
}
}}
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
@@ -108,24 +144,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
</div>
}
onClick={async () => {
await toggleDashboard({
enableDashboard: !haveTraefikDashboardPortEnabled,
serverId: serverId,
})
.then(async () => {
toast.success(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
refetchDashboard();
})
.catch((error) => {
const errorMessage =
error?.message ||
"Failed to toggle dashboard. Please check if port 8080 is available.";
toast.error(errorMessage);
});
try {
await executeWithHealthCheck(() =>
toggleDashboard({
enableDashboard: !haveTraefikDashboardPortEnabled,
serverId: serverId,
}),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
"Failed to toggle dashboard. Please check if port 8080 is available.";
toast.error(errorMessage);
}
}}
disabled={toggleDashboardIsLoading}
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
type="default"
>
<DropdownMenuItem

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
const { mutateAsync, isLoading, error, isError } =
api.settings.writeTraefikEnv.useMutation();
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: "Traefik Env Updated",
});
const form = useForm<Schema>({
defaultValues: {
env: data || "",
@@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
env: data.env,
serverId,
})
.then(async () => {
toast.success("Traefik Env Updated");
})
.catch(() => {
toast.error("Error updating the Traefik env");
});
try {
await executeWithHealthCheck(() =>
mutateAsync({
env: data.env,
serverId,
}),
);
} catch {
toast.error("Error updating the Traefik env");
}
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
<DialogFooter>
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
isLoading={isLoading || isHealthCheckExecuting}
disabled={canEdit || isLoading || isHealthCheckExecuting}
form="hook-form-update-server-traefik-config"
type="submit"
>

View File

@@ -1,6 +1,7 @@
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";
import { useFieldArray, useForm } from "react-hook-form";
@@ -76,11 +77,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
api.settings.updateTraefikPorts.useMutation();
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
onSuccess: () => {
refetchPorts();
setOpen(false);
},
});
useEffect(() => {
if (currentPorts) {
@@ -99,11 +108,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const onSubmit = async (data: TraefikPortsForm) => {
try {
await updatePorts({
serverId,
additionalPorts: data.ports,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
await executeWithHealthCheck(() =>
updatePorts({
serverId,
additionalPorts: data.ports,
}),
);
setOpen(false);
} catch (error) {
toast.error((error as Error).message || "Error updating Traefik ports");
@@ -317,7 +327,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
type="submit"
variant="default"
className="text-sm"
isLoading={isLoading}
isLoading={isLoading || isHealthCheckExecuting}
>
Save
</Button>

View File

@@ -257,3 +257,23 @@ export const PushoverIcon = ({ className }: Props) => {
</svg>
);
};
export const ResendIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 24 24"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
<path
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
};

View File

@@ -4,35 +4,21 @@ import { cn } from "@/lib/utils";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { api } from "@/utils/api";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
const logoUrl =
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
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">
<div className="absolute inset-0 bg-muted" />
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
style={{
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
}}
/>
)}
<Link
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
{appName}
<Logo className="size-10" />
Dokploy
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">

View File

@@ -24,7 +24,6 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Server,
ShieldCheck,
@@ -405,8 +404,8 @@ const MENU: Menu = {
url: "/dashboard/settings/license",
icon: Key,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -417,15 +416,6 @@ const MENU: Menu = {
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Enterprise only page shows gate if no license
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
],
help: [
@@ -556,7 +546,6 @@ function SidebarLogo() {
refetch,
isLoading,
} = api.organization.all.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
@@ -622,11 +611,7 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={
activeOrganization?.logo ||
whitelabel?.whitelabelLogoUrl ||
undefined
}
logoUrl={activeOrganization?.logo || undefined}
/>
</div>
<div
@@ -636,9 +621,7 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ??
whitelabel?.whitelabelAppName ??
"Select Organization"}
{activeOrganization?.name ?? "Select Organization"}
</p>
</div>
</div>

View File

@@ -101,27 +101,32 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const scopes = data.scopes.filter(Boolean).length
? data.scopes.filter(Boolean)
: DEFAULT_SCOPES;
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domain,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
mapping: {
const isAzure = data.issuer.includes("login.microsoftonline.com");
const mapping = isAzure
? {
id: "sub",
email: "preferred_username",
emailVerified: "email_verified",
name: "name",
}
: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
},
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
mapping,
},
});

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -52,12 +52,7 @@ const samlProviderSchema = z.object({
.url("Invalid URL")
.trim(),
cert: z.string().min(1, "IdP signing certificate is required"),
callbackUrl: z
.string()
.min(1, "Callback URL is required")
.url("Invalid URL")
.trim(),
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
idpMetadataXml: z.string().optional(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
@@ -72,8 +67,7 @@ const formDefaultValues: SamlProviderForm = {
domains: [""],
entryPoint: "",
cert: "",
callbackUrl: "",
audience: "",
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
@@ -81,6 +75,14 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
@@ -95,24 +97,38 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const onSubmit = async (data: SamlProviderForm) => {
try {
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
// maybe add the /saml/metadata endpoint to the baseURL
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
const generateSpMetadata = (providerId: string) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domain,
domains: data.domains,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: data.callbackUrl,
audience: data.audience,
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: baseURL,
idpMetadata: data.idpMetadataXml?.trim()
? { metadata: data.idpMetadataXml.trim() }
: undefined,
spMetadata: {
entityID: data.audience,
metadata: generateSpMetadata(data.providerId),
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
},
},
});
@@ -268,39 +284,29 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="callbackUrl"
name="idpMetadataXml"
render={({ field }) => (
<FormItem>
<FormLabel>Callback URL (ACS)</FormLabel>
<FormLabel>IdP metadata XML (optional)</FormLabel>
<FormControl>
<Input
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
<Textarea
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
rows={5}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormDescription>
Use the callback URL shown in your IdP app config for this
provider.
Some IdPs require full metadata; paste the XML here to
override issuer/entry point/cert.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="audience"
render={({ field }) => (
<FormItem>
<FormLabel>Audience (Entity ID)</FormLabel>
<FormControl>
<Input placeholder="https://yourapp.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"

View File

@@ -340,7 +340,10 @@ export const SSOSettings = () => {
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{baseURL || "{baseURL}"}/api/auth/sso/callback/
{baseURL || "{baseURL}"}
{detailsProvider.samlConfig
? "/api/auth/sso/saml2/callback/"
: "/api/auth/sso/callback/"}
{detailsProvider.providerId}
</p>
{!baseURL && (

View File

@@ -1,290 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const whitelabelSchema = z.object({
whitelabelAppName: z.string().min(1).max(100),
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginTitle: z.string().max(200).optional(),
whitelabelLoginSubtitle: z.string().max(500).optional(),
whitelabelLoginBackgroundImageUrl: z
.union([z.string().url(), z.literal("")])
.optional(),
});
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
export function WhitelabelSettings() {
const { data: settings, isLoading } =
api.settings.getWebServerSettings.useQuery();
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
api.settings.updateWhitelabelSettings.useMutation();
const utils = api.useUtils();
const form = useForm<WhitelabelFormValues>({
resolver: zodResolver(whitelabelSchema),
defaultValues: {
whitelabelAppName: "Dokploy",
whitelabelLogoUrl: "",
whitelabelLoginLogoUrl: "",
whitelabelFaviconUrl: "",
whitelabelLoginTitle: "",
whitelabelLoginSubtitle: "",
whitelabelLoginBackgroundImageUrl: "",
},
});
useEffect(() => {
if (settings) {
form.reset({
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? "",
});
}
}, [settings, form]);
const onSubmit = async (values: WhitelabelFormValues) => {
try {
await updateWhitelabel({
whitelabelAppName: values.whitelabelAppName || null,
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
whitelabelLoginBackgroundImageUrl:
values.whitelabelLoginBackgroundImageUrl || undefined,
});
toast.success("Whitelabel settings saved");
utils.settings.getWebServerSettings.invalidate();
utils.settings.getWhitelabelSettings.invalidate();
} catch (e) {
toast.error("Failed to save whitelabel settings");
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading whitelabel settings...
</span>
</div>
);
}
return (
<div className="flex flex-col gap-4 rounded-lg ">
<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">Whitelabeling</CardTitle>
</div>
<CardDescription>
Customize the application name, logos, and login page for your brand.
Leave URLs empty to use defaults.
</CardDescription>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="space-y-4 pt-2 border-t">
<div>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-sm text-muted-foreground">
Application name and main logo (sidebar, header).
</p>
</div>
<FormField
control={form.control}
name="whitelabelAppName"
render={({ field }) => (
<FormItem>
<FormLabel>Application name</FormLabel>
<FormControl>
<Input
placeholder="Dokploy"
{...field}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo shown in the sidebar and header.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelFaviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon.ico"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-6 border-t">
<div>
<h3 className="text-sm font-medium">Login page</h3>
<p className="text-sm text-muted-foreground">
Customize the sign-in and registration screens.
</p>
</div>
<FormField
control={form.control}
name="whitelabelLoginLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo on the login and register pages. Falls back to the main
logo if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login title</FormLabel>
<FormControl>
<Input
placeholder="Sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginSubtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login subtitle</FormLabel>
<FormControl>
<Input
placeholder="Enter your email and password to sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginBackgroundImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login background image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/background.jpg"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Optional background image for the login page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,18 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"provider_id" text NOT NULL,
"user_id" text,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;

View File

@@ -1,13 +0,0 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" text,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,10 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
CREATE TABLE "resend" (
"resendId" text PRIMARY KEY NOT NULL,
"apiKey" text NOT NULL,
"fromAddress" text NOT NULL,
"toAddress" text[] NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "gitlab" ADD COLUMN "gitlabInternalUrl" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "gitea" ADD COLUMN "giteaInternalUrl" text;

View File

@@ -1,5 +1,5 @@
{
"id": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
"id": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
"prevId": "5958b029-1fb9-4a44-be24-c96b4e899b84",
"version": "7",
"dialect": "postgresql",
@@ -6309,6 +6309,102 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6441,6 +6537,13 @@
"primaryKey": false,
"notNull": false
},
"isValidEnterpriseLicense": {
"name": "isValidEnterpriseLicense",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
@@ -6459,6 +6562,12 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},

View File

@@ -1,6 +1,6 @@
{
"id": "9192b74d-8589-483e-a188-32d60d18c112",
"prevId": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
"id": "45344442-d8aa-48cf-b45f-0c869acbd620",
"prevId": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -639,89 +639,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.two_factor": {
"name": "two_factor",
"schema": "",
@@ -4575,6 +4492,12 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4666,6 +4589,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4845,6 +4781,43 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6392,6 +6365,102 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6524,6 +6593,13 @@
"primaryKey": false,
"notNull": false
},
"isValidEnterpriseLicense": {
"name": "isValidEnterpriseLicense",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
@@ -6542,6 +6618,12 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7040,6 +7122,7 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

View File

@@ -1,6 +1,6 @@
{
"id": "4b2adb61-29b2-456d-829f-67faa7c64982",
"prevId": "9192b74d-8589-483e-a188-32d60d18c112",
"id": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
"prevId": "45344442-d8aa-48cf-b45f-0c869acbd620",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -344,6 +344,13 @@
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
@@ -639,89 +646,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.two_factor": {
"name": "two_factor",
"schema": "",
@@ -4575,6 +4499,12 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4666,6 +4596,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4845,6 +4788,43 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6392,6 +6372,102 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6549,6 +6625,12 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7047,6 +7129,7 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

View File

@@ -1,6 +1,6 @@
{
"id": "2d5967fa-7d7c-4efe-b573-02c14983be02",
"prevId": "4b2adb61-29b2-456d-829f-67faa7c64982",
"id": "9cf69a2e-b38d-4ed5-ad01-27065deac7f6",
"prevId": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -344,6 +344,13 @@
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
@@ -3211,6 +3218,12 @@
"notNull": true,
"default": "'https://gitlab.com'"
},
"gitlabInternalUrl": {
"name": "gitlabInternalUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"application_id": {
"name": "application_id",
"type": "text",
@@ -4492,6 +4505,12 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4583,6 +4602,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4762,6 +4794,43 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6562,6 +6631,12 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7060,6 +7135,7 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

File diff suppressed because it is too large Load Diff

View File

@@ -964,29 +964,36 @@
{
"idx": 137,
"version": "7",
"when": 1769616589728,
"tag": "0137_naive_power_pack",
"when": 1770274109332,
"tag": "0137_colossal_sally_floyd",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1769745328628,
"tag": "0138_common_mathemanic",
"when": 1770324882572,
"tag": "0138_pretty_ironclad",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1769746948088,
"tag": "0139_smiling_havok",
"when": 1770442690721,
"tag": "0139_brave_bloodstorm",
"breakpoints": true
},
{
"idx": 140,
"version": "7",
"when": 1769854977685,
"tag": "0140_great_lightspeed",
"when": 1770489900075,
"tag": "0140_lame_mattie_franklin",
"breakpoints": true
},
{
"idx": 141,
"version": "7",
"when": 1770490719123,
"tag": "0141_plain_earthquake",
"breakpoints": true
}
]

View File

@@ -24,6 +24,8 @@ try {
.build({
entryPoints: {
server: "server/server.ts",
migration: "migration.ts",
"wait-for-postgres": "wait-for-postgres.ts",
"reset-password": "reset-password.ts",
"reset-2fa": "reset-2fa.ts",
},

View File

@@ -0,0 +1,92 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
const HEALTH_CHECK_URL = "/api/health";
export interface UseHealthCheckAfterMutationOptions {
/**
* Delay in ms before starting to poll the health endpoint.
* Gives time for the service (e.g. Traefik) to restart.
* @default 5000
*/
initialDelay?: number;
/**
* Delay in ms between each health check poll.
* @default 2000
*/
pollInterval?: number;
/**
* Message shown in toast when the operation completes successfully.
*/
successMessage: string;
/**
* Callback when health check passes. Use for refetching data.
*/
onSuccess?: () => void | Promise<void>;
/**
* If true, reloads the page when health check passes (e.g. for server update).
* @default false
*/
reloadOnSuccess?: boolean;
}
export const useHealthCheckAfterMutation = ({
initialDelay = 5000,
pollInterval = 2000,
successMessage,
onSuccess,
reloadOnSuccess = false,
}: UseHealthCheckAfterMutationOptions) => {
const [isExecuting, setIsExecuting] = useState(false);
const checkHealth = useCallback(async (): Promise<boolean> => {
try {
const response = await fetch(HEALTH_CHECK_URL);
return response.ok;
} catch {
return false;
}
}, []);
const pollUntilHealthy = useCallback(async (): Promise<void> => {
const isHealthy = await checkHealth();
if (isHealthy) {
toast.success(successMessage);
if (reloadOnSuccess) {
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
await onSuccess?.();
}
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
await pollUntilHealthy();
}, [checkHealth, successMessage, reloadOnSuccess, onSuccess, pollInterval]);
const execute = useCallback(
async <T>(mutationFn: () => Promise<T>): Promise<T> => {
setIsExecuting(true);
try {
const result = await mutationFn();
// Give time for the service to restart before polling
await new Promise((resolve) => setTimeout(resolve, initialDelay));
await pollUntilHealthy();
return result;
} finally {
setIsExecuting(false);
}
},
[initialDelay, pollUntilHealthy],
);
return { execute, isExecuting };
};

View File

@@ -1,15 +1,17 @@
{
"name": "dokploy",
"version": "v0.26.7",
"version": "v0.27.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "npm run build-server && npm run build-next",
"start": "node -r dotenv/config dist/server.mjs",
"start": "node -r dotenv/config dist/migration.mjs && node -r dotenv/config dist/server.mjs",
"build-server": "tsx esbuild.config.ts",
"build-next": "next build",
"build-next": "next build --webpack",
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
"wait-for-postgres": "node -r dotenv/config dist/wait-for-postgres.mjs",
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
@@ -37,6 +39,7 @@
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
@@ -98,7 +101,7 @@
"better-auth": "1.4.18",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
"bullmq": "5.67.3",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -107,7 +110,7 @@
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.39.3",
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
@@ -117,7 +120,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.0.10",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
@@ -165,7 +168,7 @@
"@types/js-cookie": "^3.0.6",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -175,7 +178,7 @@
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",
"autoprefixer": "10.4.12",
"drizzle-kit": "^0.30.6",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",
"memfs": "^4.17.2",
@@ -183,7 +186,7 @@
"tsx": "^4.16.2",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^1.6.1"
"vitest": "^4.0.18"
},
"ct3aMetadata": {
"initVersion": "7.25.2"

View File

@@ -355,6 +355,11 @@ export default async function handler(
action === "labeled" ||
action === "unlabeled"
) {
const shouldCreateDeployment =
action === "opened" ||
action === "synchronize" ||
action === "reopened";
const repository = githubBody?.repository?.name;
const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref;
@@ -475,7 +480,7 @@ export default async function handler(
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult) {
if (!previewDeploymentResult && shouldCreateDeployment) {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
@@ -497,21 +502,23 @@ export default async function handler(
previewDeploymentId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
if (previewDeploymentId) {
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
return res.status(200).json({ message: "Apps Deployed" });
}

View File

@@ -15,7 +15,9 @@ const parseState = (state: string): string | null => {
// Helper to fetch access token from Gitea
const fetchAccessToken = async (gitea: Gitea, code: string) => {
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
// Use internal URL for token exchange when Gitea is on same instance as Dokploy
const baseUrl = gitea.giteaInternalUrl || gitea.giteaUrl;
const response = await fetch(`${baseUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -9,6 +9,7 @@ export interface Gitea {
refreshToken: string | null;
expiresAt: number | null;
giteaUrl: string;
giteaInternalUrl: string | null;
clientId: string | null;
clientSecret: string | null;
organizationName?: string;

View File

@@ -12,7 +12,9 @@ export default async function handler(
}
const gitlab = await findGitlabById(gitlabId as string);
const gitlabUrl = new URL(gitlab.gitlabUrl);
// Use internal URL for token exchange when GitLab is on same instance as Dokploy
const baseUrl = gitlab.gitlabInternalUrl || gitlab.gitlabUrl;
const gitlabUrl = new URL(baseUrl);
const headers: HeadersInit = {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -36,14 +36,6 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {

View File

@@ -1,84 +0,0 @@
import { 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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
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: "Enterprise Whitelabeling",
description:
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
ctaLabel: "Go to License",
}}
>
<WhitelabelSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -59,7 +59,6 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -213,27 +212,17 @@ export default function Home({ IS_CLOUD }: Props) {
</>
);
const loginLogoUrl =
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
const loginSubtitle =
whitelabel?.whitelabelLoginSubtitle ??
"Enter your email and password to sign in";
return (
<>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
<div className="flex flex-row items-center justify-center gap-2">
<Logo
className="size-12"
logoUrl={loginLogoUrl ?? undefined}
/>
{loginTitle}
<Logo className="size-12" />
Sign in
</div>
</h1>
<p className="text-sm text-muted-foreground">
{loginSubtitle}
Enter your email and password to sign in
</p>
</div>
{error && (

View File

@@ -63,7 +63,7 @@ export default function Home() {
const onSubmit = async (values: Login) => {
setIsLoading(true);
const { error } = await authClient.forgetPassword({
const { error } = await authClient.requestPasswordReset({
email: values.email,
redirectTo: "/reset-password",
});

View File

@@ -57,9 +57,11 @@ import {
apiUpdateApplication,
applications,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByApplication,
getJobsByApplicationId,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
@@ -240,6 +242,15 @@ export const applicationRouter = createTRPCRouter({
.where(eq(applications.applicationId, input.applicationId))
.returning();
if (!IS_CLOUD) {
const queueJobs = await getJobsByApplicationId(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
const cleanupOperations = [
async () => await deleteAllMiddlewares(application),
async () => await removeDeployments(application),

View File

@@ -58,9 +58,11 @@ import {
apiUpdateCompose,
compose as composeTable,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByCompose,
getJobsByComposeId,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
@@ -222,6 +224,15 @@ export const composeRouter = createTRPCRouter({
.where(eq(composeTable.composeId, input.composeId))
.returning();
if (!IS_CLOUD) {
const queueJobs = await getJobsByComposeId(input.composeId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
const cleanupOperations = [
async () => await removeCompose(composeResult, input.deleteVolumes),
async () => await removeDeploymentsByComposeId(composeResult),

View File

@@ -5,7 +5,6 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,

View File

@@ -1,5 +1,6 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMariadb,
createMount,
@@ -162,9 +163,9 @@ export const mariadbRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMariaDB)
.mutation(async ({ input, ctx }) => {
const mongo = await findMariadbById(input.mariadbId);
const mariadb = await findMariadbById(input.mariadbId);
if (
mongo.environment.project.organizationId !==
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -172,11 +173,25 @@ export const mariadbRouter = createTRPCRouter({
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mariadb.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMariadbById(input.mariadbId, {
externalPort: input.externalPort,
});
await deployMariadb(input.mariadbId);
return mongo;
return mariadb;
}),
deploy: protectedProcedure
.input(apiDeployMariaDB)

View File

@@ -1,5 +1,6 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMongo,
createMount,
@@ -189,6 +190,20 @@ export const mongoRouter = createTRPCRouter({
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mongo.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMongoById(input.mongoId, {
externalPort: input.externalPort,
});

View File

@@ -1,5 +1,6 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createMysql,
@@ -177,9 +178,9 @@ export const mysqlRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMySql)
.mutation(async ({ input, ctx }) => {
const mongo = await findMySqlById(input.mysqlId);
const mysql = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -187,11 +188,25 @@ export const mysqlRouter = createTRPCRouter({
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mysql.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMySqlById(input.mysqlId, {
externalPort: input.externalPort,
});
await deployMySql(input.mysqlId);
return mongo;
return mysql;
}),
deploy: protectedProcedure
.input(apiDeployMySql)

View File

@@ -6,6 +6,7 @@ import {
createLarkNotification,
createNtfyNotification,
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -19,6 +20,7 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
@@ -29,6 +31,7 @@ import {
updateLarkNotification,
updateNtfyNotification,
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -50,6 +53,7 @@ import {
apiCreateLark,
apiCreateNtfy,
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
@@ -60,6 +64,7 @@ import {
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
@@ -69,6 +74,7 @@ import {
apiUpdateLark,
apiUpdateNtfy,
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -302,6 +308,63 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createResend: adminProcedure
.input(apiCreateResend)
.mutation(async ({ input, ctx }) => {
try {
return await createResendNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateResend: adminProcedure
.input(apiUpdateResend)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (notification.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateResendNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the notification",
cause: error,
});
}
}),
testResendConnection: adminProcedure
.input(apiTestResendConnection)
.mutation(async ({ input }) => {
try {
await sendResendNotification(
input,
"Test Email",
"<p>Hi, From Dokploy 👋</p>",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
remove: adminProcedure
.input(apiFindOneNotification)
.mutation(async ({ input, ctx }) => {
@@ -344,6 +407,7 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -702,6 +766,7 @@ export const notificationRouter = createTRPCRouter({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
resend: true,
},
});
}),

View File

@@ -1,5 +1,6 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createPostgres,
@@ -192,6 +193,20 @@ export const postgresRouter = createTRPCRouter({
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
postgres.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updatePostgresById(input.postgresId, {
externalPort: input.externalPort,
});

View File

@@ -506,7 +506,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
const { backupId, appName: _appName, ...rest } = backup;
await createBackup({
...rest,
postgresId: newPostgres.postgresId,
@@ -542,7 +542,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
const { backupId, appName: _appName, ...rest } = backup;
await createBackup({
...rest,
mariadbId: newMariadb.mariadbId,
@@ -578,7 +578,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
const { backupId, appName: _appName, ...rest } = backup;
await createBackup({
...rest,
mongoId: newMongo.mongoId,
@@ -614,7 +614,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, ...rest } = backup;
const { backupId, appName: _appName, ...rest } = backup;
await createBackup({
...rest,
mysqlId: newMysql.mysqlId,

View File

@@ -26,6 +26,13 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to activate a license key",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -117,6 +124,14 @@ export const licenseKeyRouter = createTRPCRouter({
message: "No license key found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to deactivate a license key",
});
}
await deactivateLicenseKey(currentUser.licenseKey);
await db
.update(user)

View File

@@ -1,6 +1,8 @@
import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider } from "@dokploy/server/db/schema";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
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";
@@ -12,20 +14,6 @@ import {
} from "@/server/api/trpc";
import { db } from "@/server/db";
function requestToHeaders(req: {
headers?: Record<string, string | string[] | undefined>;
}): Headers {
const headers = new Headers();
if (req?.headers) {
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
}
return headers;
}
export const ssoRouter = createTRPCRouter({
showSignInWithSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
@@ -73,6 +61,28 @@ export const ssoRouter = createTRPCRouter({
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
// Obtener el provider antes de eliminarlo para obtener sus dominios
const providerToDelete = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
domain: true,
issuer: true,
},
});
if (!providerToDelete) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
const [deleted] = await db
.delete(ssoProvider)
.where(
@@ -92,6 +102,24 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -99,15 +127,54 @@ export const ssoRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const organizationId = ctx.session.activeOrganizationId;
const result = await auth.registerSSOProvider({
const providers = await db.query.ssoProvider.findMany({
columns: {
domain: true,
},
});
for (const provider of providers) {
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {
...input,
organizationId,
domain,
},
headers: requestToHeaders(ctx.req),
});
console.log(result);
return { success: true };
}),
});

View File

@@ -1,5 +1,6 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createRedis,
@@ -201,9 +202,9 @@ export const redisRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortRedis)
.mutation(async ({ input, ctx }) => {
const mongo = await findRedisById(input.redisId);
const redis = await findRedisById(input.redisId);
if (
mongo.environment.project.organizationId !==
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -211,11 +212,25 @@ export const redisRouter = createTRPCRouter({
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
redis.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateRedisById(input.redisId, {
externalPort: input.externalPort,
});
await deployRedis(input.redisId);
return mongo;
return redis;
}),
deploy: protectedProcedure
.input(apiDeployRedis)

View File

@@ -1,4 +1,5 @@
import {
CLEANUP_CRON_JOB,
canAccessToTraefikFiles,
checkGPUStatus,
checkPortInUse,
@@ -12,7 +13,6 @@ import {
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
getDokployImage,
getDokployImageTag,
getLogCleanupStatus,
getUpdateData,
@@ -22,7 +22,6 @@ import {
paths,
prepareEnvironmentVariables,
processLogs,
pullLatestRelease,
readConfig,
readConfigInPath,
readDirectory,
@@ -63,17 +62,16 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
apiUpdateWhitelabel,
projects,
server,
} from "@/server/db/schema";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
import { appRouter } from "../root";
import {
adminProcedure,
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
@@ -86,57 +84,6 @@ export const settingsRouter = createTRPCRouter({
const settings = await getWebServerSettings();
return settings;
}),
getWhitelabelSettings: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
if (!settings) return null;
return {
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? null,
};
}),
updateWhitelabelSettings: enterpriseProcedure
.input(apiUpdateWhitelabel)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
const updates: Record<string, unknown> = {};
if (input.whitelabelAppName !== undefined)
updates.whitelabelAppName = input.whitelabelAppName;
if (input.whitelabelLogoUrl !== undefined)
updates.whitelabelLogoUrl =
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
if (input.whitelabelLoginLogoUrl !== undefined)
updates.whitelabelLoginLogoUrl =
input.whitelabelLoginLogoUrl === ""
? null
: input.whitelabelLoginLogoUrl;
if (input.whitelabelFaviconUrl !== undefined)
updates.whitelabelFaviconUrl =
input.whitelabelFaviconUrl === ""
? null
: input.whitelabelFaviconUrl;
if (input.whitelabelLoginTitle !== undefined)
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
if (input.whitelabelLoginSubtitle !== undefined)
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
updates.whitelabelLoginBackgroundImageUrl =
input.whitelabelLoginBackgroundImageUrl === ""
? null
: input.whitelabelLoginBackgroundImageUrl;
const updated = await updateWebServerSettings(updates as any);
return updated;
}),
reloadServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
@@ -170,15 +117,21 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
cleanAllDeploymentQueue: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
}
return cleanAllDeploymentQueue();
}),
reloadTraefik: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
try {
await reloadDockerResource("dokploy-traefik", input?.serverId);
} catch (err) {
console.error(err);
}
// Run in background so the request returns immediately; avoids proxy timeouts.
void reloadDockerResource("dokploy-traefik", input?.serverId).catch(
(err) => {
console.error("reloadTraefik background:", err);
},
);
return true;
}),
toggleDashboard: adminProcedure
@@ -213,10 +166,14 @@ export const settingsRouter = createTRPCRouter({
newPorts = ports.filter((port) => port.targetPort !== 8080);
}
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
// Avoids proxy timeouts (520) while Traefik is recreated.
void writeTraefikSetup({
env: preparedEnv,
additionalPorts: newPorts,
serverId: input.serverId,
}).catch((err) => {
console.error("toggleDashboard background writeTraefikSetup:", err);
});
return true;
}),
@@ -342,12 +299,12 @@ export const settingsRouter = createTRPCRouter({
}
if (IS_CLOUD) {
await schedule({
cronSchedule: "0 0 * * *",
cronSchedule: CLEANUP_CRON_JOB,
serverId: input.serverId,
type: "server",
});
} else {
scheduleJob(server.serverId, "0 0 * * *", async () => {
scheduleJob(server.serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
@@ -360,7 +317,7 @@ export const settingsRouter = createTRPCRouter({
} else {
if (IS_CLOUD) {
await removeJob({
cronSchedule: "0 0 * * *",
cronSchedule: CLEANUP_CRON_JOB,
serverId: input.serverId,
type: "server",
});
@@ -375,7 +332,7 @@ export const settingsRouter = createTRPCRouter({
});
if (settingsUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
@@ -459,18 +416,17 @@ export const settingsRouter = createTRPCRouter({
return true;
}
await pullLatestRelease();
// This causes restart of dokploy, thus it will not finish executing properly, so don't await it
// Status after restart is checked via frontend /api/health endpoint
void spawnAsync("docker", [
"service",
"update",
"--force",
"--image",
getDokployImage(),
"dokploy",
]);
const data = await getUpdateData(packageInfo.version);
if (data.updateAvailable) {
void spawnAsync("docker", [
"service",
"update",
"--force",
"--image",
`dokploy/dokploy:${data.latestVersion}`,
"dokploy",
]);
}
return true;
}),
@@ -657,12 +613,14 @@ export const settingsRouter = createTRPCRouter({
const envs = prepareEnvironmentVariables(input.env);
const ports = await readPorts("dokploy-traefik", input?.serverId);
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
env: envs,
additionalPorts: ports,
serverId: input.serverId,
}).catch((err) => {
console.error("writeTraefikEnv background writeTraefikSetup:", err);
});
return true;
}),
haveTraefikDashboardPortEnabled: adminProcedure
@@ -806,16 +764,13 @@ export const settingsRouter = createTRPCRouter({
return haveServers.length > 0 || haveProjects.length > 0;
}),
health: publicProcedure.query(async () => {
if (IS_CLOUD) {
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
}
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
}
return { status: "not_cloud" };
}),
setupGPU: adminProcedure
.input(
@@ -910,10 +865,16 @@ export const settingsRouter = createTRPCRouter({
}
const preparedEnv = prepareEnvironmentVariables(env);
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
env: preparedEnv,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
}).catch((err) => {
console.error(
"updateTraefikPorts background writeTraefikSetup:",
err,
);
});
return true;
} catch (error) {

View File

@@ -9,6 +9,7 @@ import {
IS_CLOUD,
removeUserById,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -509,15 +510,16 @@ export const userRouter = createTRPCRouter({
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const resend = notification.resend;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
if (!email && !resend) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
message: "Email provider not found",
});
}
@@ -532,16 +534,29 @@ export const userRouter = createTRPCRouter({
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
const htmlContent = `
\t\t\t\t<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
\t\t\t\t`;
if (email) {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
} else if (resend) {
await sendResendNotification(
{
...resend,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
}
} catch (error) {
console.log(error);
throw error;

View File

@@ -21,7 +21,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -54,6 +54,7 @@ export const volumeBackupsRouter = createTRPCRouter({
redis: true,
compose: true,
},
orderBy: [desc(volumeBackups.createdAt)],
});
}),
create: protectedProcedure

View File

@@ -7,10 +7,8 @@
* need to use are documented accordingly near the end.
*/
import { user as userSchema } from "@dokploy/server/db/schema";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { eq } from "drizzle-orm";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import {
@@ -33,7 +31,14 @@ import { db } from "@/server/db";
*/
interface CreateContextOptions {
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
user:
| (User & {
role: "member" | "admin" | "owner";
ownerId: string;
enableEnterpriseFeatures: boolean;
isValidEnterpriseLicense: boolean;
})
| null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
@@ -234,17 +239,9 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const currentUser = await ctx.db.query.user.findFirst({
where: eq(userSchema.id, ctx.user.id),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
if (
!currentUser?.enableEnterpriseFeatures ||
!currentUser.isValidEnterpriseLicense
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
) {
throw new TRPCError({
code: "FORBIDDEN",

View File

@@ -3,12 +3,23 @@ import {
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { Queue } from "bullmq";
import { deploymentWorker } from "./deployments-queue";
import { redisConfig } from "./redis-connection";
const myQueue = new Queue("deployments", {
connection: redisConfig,
});
export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.applicationId === applicationId);
};
export const getJobsByComposeId = async (composeId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.composeId === composeId);
};
process.on("SIGTERM", () => {
myQueue.close();
process.exit(0);
@@ -34,6 +45,11 @@ export const cleanQueuesByApplication = async (applicationId: string) => {
}
};
export const cleanAllDeploymentQueue = async () => {
deploymentWorker.cancelAllJobs("User requested cancellation");
return true;
};
export const cleanQueuesByCompose = async (composeId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);

View File

@@ -15,7 +15,6 @@ import {
} from "@dokploy/server";
import { config } from "dotenv";
import next from "next";
import { migration } from "@/server/db/migration";
import packageInfo from "../package.json";
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
@@ -60,7 +59,6 @@ void app.prepare().then(async () => {
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
createDefaultMiddlewares();
await initializeNetwork();
await migration();
await initCronJobs();
await initSchedules();
await initCancelDeployments();
@@ -68,10 +66,6 @@ void app.prepare().then(async () => {
await sendDokployRestartNotifications();
}
if (IS_CLOUD && process.env.NODE_ENV === "production") {
await migration();
}
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
await initEnterpriseBackupCronJobs();

View File

@@ -1,6 +1,4 @@
import { getPublicIpWithFallback } from "@dokploy/server/index";
const LICENSE_KEY_URL = process.env.LICENSE_KEY_URL || "http://localhost:4002";
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
export const validateLicenseKey = async (licenseKey: string) => {
try {

View File

@@ -34,14 +34,13 @@ export const setupDeploymentLogsWebSocketServer = (
// Generate unique connection ID for tracking
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
if (!logPath) {
console.log(`[${connectionId}] logPath no provided`);
ws.close(4000, "logPath no provided");
return;
}
if (!readValidDirectory(logPath)) {
if (!readValidDirectory(logPath, serverId)) {
ws.close(4000, "Invalid log path");
return;
}

View File

@@ -32,8 +32,11 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell);
};
export const readValidDirectory = (directory: string) => {
const { BASE_PATH } = paths();
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);

View File

@@ -1,5 +1,8 @@
import { exit } from "node:process";
import { execAsync } from "@dokploy/server";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
import { initializeRedis } from "@dokploy/server/setup/redis-setup";
@@ -12,6 +15,7 @@ import {
createDefaultServerTraefikConfig,
createDefaultTraefikConfig,
initializeStandaloneTraefik,
TRAEFIK_VERSION,
} from "@dokploy/server/setup/traefik-setup";
(async () => {
@@ -22,7 +26,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync("docker pull traefik:v3.6.7");
await execAsync(`docker pull traefik:v${TRAEFIK_VERSION}`);
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();

View File

@@ -0,0 +1,91 @@
import net from "node:net";
import { URL } from "node:url";
import { dbUrl } from "@dokploy/server/db/constants";
const TIMEOUT_MS = Number(process.env.POSTGRES_WAIT_TIMEOUT || 120_000);
const RETRY_DELAY_MS = Number(process.env.POSTGRES_WAIT_RETRY || 2000);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolvePostgresTarget(): { host: string; port: number } {
const databaseUrl = dbUrl;
if (!databaseUrl) {
console.error("[wait-for-postgres] DATABASE_URL is not set");
process.exit(1);
}
try {
const url = new URL(databaseUrl);
const host = url.hostname;
const port = Number(url.port || 5432);
if (!host) {
throw new Error("DATABASE_URL has no hostname");
}
return { host, port };
} catch (err) {
console.error("[wait-for-postgres] Invalid DATABASE_URL:", databaseUrl);
process.exit(1);
}
}
function checkTcpConnection(host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
socket.setTimeout(3000);
socket.on("connect", () => {
socket.end();
resolve();
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("Connection timeout"));
});
socket.on("error", reject);
});
}
async function waitForPostgres() {
const { host, port } = resolvePostgresTarget();
const start = Date.now();
console.log(
`[wait-for-postgres] Waiting for postgres at ${host}:${port} (timeout ${TIMEOUT_MS}ms)`,
);
while (true) {
try {
await checkTcpConnection(host, port);
console.log("[wait-for-postgres] Postgres is reachable ✅");
return;
} catch {
const elapsed = Date.now() - start;
if (elapsed > TIMEOUT_MS) {
console.error(
`[wait-for-postgres] Timeout after ${elapsed}ms. Postgres not reachable ❌`,
);
process.exit(1);
}
console.log(
`[wait-for-postgres] Postgres not ready yet, retrying in ${RETRY_DELAY_MS}ms...`,
);
await sleep(RETRY_DELAY_MS);
}
}
}
waitForPostgres().catch((err) => {
console.error("[wait-for-postgres] Fatal error:", err);
process.exit(1);
});

View File

@@ -11,10 +11,10 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"bullmq": "5.4.2",
"bullmq": "5.67.3",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.39.3",
"hono": "^4.7.10",
"drizzle-orm": "^0.41.0",
"hono": "^4.11.7",
"ioredis": "5.4.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.17.51",
"@types/node": "^20.16.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"tsx": "^4.16.2",

View File

@@ -47,25 +47,25 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
result = await removeJob({
backupId: data.backupId,
type: "backup",
cronSchedule: job.pattern,
cronSchedule: job.pattern || "",
});
} else if (data.type === "server") {
result = await removeJob({
serverId: data.serverId,
type: "server",
cronSchedule: job.pattern,
cronSchedule: job.pattern || "",
});
} else if (data.type === "schedule") {
result = await removeJob({
scheduleId: data.scheduleId,
type: "schedule",
cronSchedule: job.pattern,
cronSchedule: job.pattern || "",
});
} else if (data.type === "volume-backup") {
result = await removeJob({
volumeBackupId: data.volumeBackupId,
type: "volume-backup",
cronSchedule: job.pattern,
cronSchedule: job.pattern || "",
});
}
logger.info({ result }, "Job removed");

View File

@@ -1,13 +1,11 @@
import { Queue, type RepeatableJob } from "bullmq";
import IORedis from "ioredis";
import { logger } from "./logger.js";
import type { QueueJob } from "./schema.js";
export const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
export const jobQueue = new Queue("backupQueue", {
connection,
connection: {
url: process.env.REDIS_URL!,
},
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,

View File

@@ -1,4 +1,5 @@
import {
CLEANUP_CRON_JOB,
cleanupAll,
findBackupById,
findScheduleById,
@@ -125,7 +126,7 @@ export const initializeJobs = async () => {
scheduleJob({
serverId,
type: "server",
cronSchedule: "0 0 * * *",
cronSchedule: CLEANUP_CRON_JOB,
});
}

View File

@@ -1,6 +1,5 @@
import { type Job, Worker } from "bullmq";
import { logger } from "./logger.js";
import { connection } from "./queue.js";
import type { QueueJob } from "./schema.js";
import { runJobs } from "./utils.js";
@@ -12,7 +11,9 @@ export const firstWorker = new Worker(
},
{
concurrency: 100,
connection,
connection: {
url: process.env.REDIS_URL!,
},
},
);
export const secondWorker = new Worker(
@@ -23,7 +24,9 @@ export const secondWorker = new Worker(
},
{
concurrency: 100,
connection,
connection: {
url: process.env.REDIS_URL!,
},
},
);
@@ -35,6 +38,8 @@ export const thirdWorker = new Worker(
},
{
concurrency: 100,
connection,
connection: {
url: process.env.REDIS_URL!,
},
},
);

View File

@@ -24,7 +24,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",

View File

@@ -37,7 +37,7 @@
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@better-auth/utils": "0.2.4",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
@@ -57,7 +57,7 @@
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "^0.39.3",
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"yaml": "2.8.1",
"lodash": "4.17.21",
@@ -75,6 +75,7 @@
"qrcode": "^1.5.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "^6.0.2",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
@@ -91,7 +92,7 @@
"@types/dockerode": "3.3.23",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -100,7 +101,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.30.6",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,14 @@ import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker();
// When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced .
export const BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";
export const paths = (isServer = false) => {
const BASE_PATH =
isServer || process.env.NODE_ENV === "production"
@@ -25,5 +31,6 @@ export const paths = (isServer = false) => {
REGISTRY_PATH: `${BASE_PATH}/registry`,
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
};
};

View File

@@ -26,7 +26,8 @@ if (DATABASE_URL) {
password,
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
} else {
console.warn(`
if (process.env.NODE_ENV !== "test") {
console.warn(`
⚠️ [DEPRECATED DATABASE CONFIG]
You are using the legacy hardcoded database credentials.
This mode WILL BE REMOVED in a future release.
@@ -34,5 +35,13 @@ if (DATABASE_URL) {
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
`);
dbUrl = "postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}
if (process.env.NODE_ENV === "production") {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
} else {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}
}

View File

@@ -155,6 +155,7 @@ export const invitation = pgTable("invitation", {
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
teamId: text("team_id"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const invitationRelations = relations(invitation, ({ one }) => ({

View File

@@ -11,6 +11,7 @@ export const gitea = pgTable("gitea", {
.primaryKey()
.$defaultFn(() => nanoid()),
giteaUrl: text("giteaUrl").default("https://gitea.com").notNull(),
giteaInternalUrl: text("giteaInternalUrl"),
redirectUri: text("redirect_uri"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
@@ -40,6 +41,7 @@ export const apiCreateGitea = createSchema.extend({
redirectUri: z.string().optional(),
name: z.string().min(1),
giteaUrl: z.string().min(1),
giteaInternalUrl: z.string().optional().nullable(),
giteaUsername: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
@@ -76,6 +78,7 @@ export const apiUpdateGitea = createSchema.extend({
name: z.string().min(1),
giteaId: z.string().min(1),
giteaUrl: z.string().min(1),
giteaInternalUrl: z.string().optional().nullable(),
giteaUsername: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),

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