Compare commits

...

337 Commits

Author SHA1 Message Date
Mauricio Siu
a0b550ace9 Merge pull request #2756 from niieani/bb/fix-null
fix: return an empty object if yaml file is empty
2025-10-05 12:10:55 -06:00
Mauricio Siu
7943c90d5d refactor: enhance middleware removal logic in Traefik configuration 2025-10-05 12:07:19 -06:00
Mauricio Siu
fc3fceb858 refactor: improve Traefik middleware configuration handling and validation 2025-10-05 12:04:21 -06:00
Mauricio Siu
1804a7c301 refactor: remove unnecessary middleware checks in Traefik config generation 2025-10-05 11:26:46 -06:00
autofix-ci[bot]
e97046c267 [autofix.ci] apply automated fixes 2025-10-05 17:14:11 +00:00
Bazyli Brzoska
080233a7cd fix: traefik needs middlewares to be empty/valid 2025-10-05 08:06:06 -07:00
Mauricio Siu
be5d65a8e3 Merge pull request #2684 from sueffuenfelf/fix/docker-terminal-dropdown-containers
fix: docker terminal dropdown not showing containers for applications of type "docker-compose"
2025-10-05 00:51:11 -06:00
Mauricio Siu
e934d4f4ce refactor: remove unused badgeStateColor variable in ShowDockerLogsStack component
- Eliminated the unused badgeStateColor variable to clean up the code.
- Improved overall readability and maintainability of the ShowDockerLogsStack component.
2025-10-05 00:48:07 -06:00
Mauricio Siu
586195b5c8 refactor: enhance DockerTerminalModal component for better prop handling
- Removed unnecessary conditional check for containerId in the main dialog open handler.
- Updated Terminal component to ensure serverId and containerId have default values, improving robustness and user experience.
2025-10-05 00:47:50 -06:00
Mauricio Siu
c8320da716 refactor: simplify props destructuring in DockerTerminalModal component
- Updated the props destructuring to directly include `serverId` instead of using a conditional spread operator.
- Improved code readability by streamlining the object structure.
2025-10-05 00:46:27 -06:00
Mauricio Siu
8a9a0e49ce refactor: remove unused state in DockerTerminal component
- Eliminated the `isConnected` state variable as it was not being utilized.
- Cleaned up imports by removing unused `useState` hook.
2025-10-05 00:45:45 -06:00
Mauricio Siu
aadb278e5f refactor: simplify WebSocket connection logic in DockerTerminal component
- Removed redundant checks for containerId before establishing WebSocket connection.
- Streamlined the connection setup and added the AttachAddon directly after the terminal is opened.
- Updated UI text to clarify the connection method.
2025-10-05 00:45:07 -06:00
Mauricio Siu
47a9bd9c86 Merge branch 'canary' into fix/docker-terminal-dropdown-containers 2025-10-05 00:43:48 -06:00
Mauricio Siu
739dc21bc0 Merge pull request #2679 from dennisimoo/custom-profile-picture
feat: add file upload support for custom profile pictures
2025-10-05 00:37:31 -06:00
Mauricio Siu
fa4724d94e Update profile-form.tsx 2025-10-05 00:35:10 -06:00
autofix-ci[bot]
32454bab61 [autofix.ci] apply automated fixes 2025-10-05 06:30:46 +00:00
Mauricio Siu
beb6f38204 Merge pull request #2599 from Harikrishnan1367709/separate-permission-for-deleting-environments-#2594
feat: Add Environment Deletion Permission Control-#2594
2025-10-05 00:26:54 -06:00
Mauricio Siu
3a0549bbd8 chore: update dokploy version to v0.25.5 in package.json 2025-10-05 00:26:37 -06:00
Mauricio Siu
4112ba9b10 refactor: reorganize user permission checks in AdvancedEnvironmentSelector
- Moved the check for user permissions to delete environments to a more logical position in the code.
- Removed redundant API query for environment data, streamlining the component's state management.
2025-10-05 00:25:18 -06:00
Mauricio Siu
fbf57739b3 feat: add canDeleteEnvironments column to member table
- Introduced a new boolean column `canDeleteEnvironments` to the `member` table with a default value of false.
- Updated journal and snapshot metadata files to include the new migration details for this change.
2025-10-05 00:19:56 -06:00
Mauricio Siu
e4f5a1d828 Merge branch 'canary' into separate-permission-for-deleting-environments-#2594 2025-10-05 00:19:01 -06:00
Mauricio Siu
3e09644877 Remove daily_jack_murdock SQL script and associated journal entry from the project. This change eliminates the canDeleteEnvironments column from the member table, streamlining the database schema. 2025-10-05 00:17:31 -06:00
Mauricio Siu
1ab576d260 Merge pull request #2598 from Harikrishnan1367709/separate-permission-for-creating-environments-#2593
feat: Add environment creation permission control-#2593
2025-10-05 00:16:39 -06:00
Mauricio Siu
0b0f507b49 feat: add functionality to create a new environment when a project is created
- Integrated the `addNewEnvironment` function into the project creation process.
- Ensured that the environment is associated with the current user and organization.
2025-10-05 00:15:02 -06:00
Mauricio Siu
fa8722f6c8 feat: add canCreateEnvironments column to member table and update metadata
- Introduced a new boolean column `canCreateEnvironments` to the `member` table with a default value of false.
- Updated journal and snapshot metadata files to include the new migration details.
2025-10-05 00:09:23 -06:00
Mauricio Siu
fb0ed494fc Merge branch 'canary' into separate-permission-for-creating-environments-#2593 2025-10-05 00:08:49 -06:00
Mauricio Siu
6d2728f5f0 chore: remove deprecated SQL migration files for member permissions
- Deleted SQL migration files `0111_magical_nova.sql` and `0112_serious_hellcat.sql` which added `canCreateEnvironments` and `canCreateEnvironmentsInProjects` columns to the `member` table.
- Updated journal and snapshot metadata files to reflect the removal of these migrations.
2025-10-05 00:08:02 -06:00
Mauricio Siu
8efc8b573c Merge pull request #2577 from robgraeber/patch-1
Fix swarm settings config placeholders
2025-10-05 00:04:24 -06:00
Mauricio Siu
644189064b Merge pull request #2232 from perinm/feature/stop-grace-period-2227
feat: Add stop_grace_period to swarm settings
2025-10-05 00:01:44 -06:00
Mauricio Siu
23c891d6fc feat: add stopGracePeriodSwarm column to multiple database tables and update journal and snapshot metadata 2025-10-04 23:57:13 -06:00
Mauricio Siu
a3f9f9b7a1 Merge branch 'canary' into feature/stop-grace-period-2227 2025-10-04 23:45:59 -06:00
Mauricio Siu
83a7b8dce5 refactor: remove stop grace period swarm migrations and snapshots 2025-10-04 23:45:32 -06:00
autofix-ci[bot]
e9b5699f8e [autofix.ci] apply automated fixes 2025-10-05 05:43:58 +00:00
Mauricio Siu
f952f53fca Merge pull request #2678 from dennisimoo/update-logos
style: replace generic icons with Gotify and Ntfy brand logos
2025-10-04 23:43:17 -06:00
autofix-ci[bot]
60db2972c7 [autofix.ci] apply automated fixes 2025-10-05 05:42:41 +00:00
Mauricio Siu
143e4be9e6 Merge pull request #2744 from Captainsalem/canary
fix: correct typo in saveGitProvider function name
2025-10-04 23:36:20 -06:00
Mauricio Siu
18e553f239 Merge pull request #2764 from Dokploy/2530-new-user-email-invitation-does-not-render-correctly-on-osxs-mailapp
chore: update better-auth to version 1.3.26 and adjust dependencies i…
2025-10-04 21:53:39 -06:00
Mauricio Siu
c41f447269 chore: downgrade better-auth to version v1.2.8-beta.7 in package.json files and update dependencies in pnpm-lock.yaml 2025-10-04 21:51:50 -06:00
Mauricio Siu
dbc4f4e4c5 chore: update better-auth to version 1.3.26 and adjust dependencies in package.json files 2025-10-04 21:45:48 -06:00
Mauricio Siu
8594ad8ece Merge pull request #2763 from Dokploy/2645-github-auto-deploy-webhook-responds-404
2645 GitHub auto deploy webhook responds 404
2025-10-04 20:59:01 -06:00
Mauricio Siu
9edd69b10d refactor: remove console log from WebDomain component 2025-10-04 20:58:30 -06:00
Mauricio Siu
4a9684bbe4 refactor: simplify URL change warning in WebDomain component 2025-10-04 20:58:07 -06:00
Mauricio Siu
4f835c6c5e feat: add warning alert for URL changes in WebDomain component 2025-10-04 20:56:38 -06:00
Bazyli Brzoska
54853098a7 fix: return an empty object if yaml file is empty 2025-10-04 17:19:24 -07:00
artemis37
cdca2ea6d2 fix: correct typo in saveGitProvider function name
- Fixed "saveGitProdiver" to "saveGitProvider" in API router
- Updated corresponding component usage to maintain consistency
2025-10-02 02:44:47 +03:00
Mauricio Siu
9f5c2dbe92 chore: update version to v0.25.4 in package.json 2025-09-28 22:32:35 -06:00
Mauricio Siu
0f9505327f Merge pull request #2710 from SimonLoir/canary
fix: add environment in buildLink for docker compose deploy notifications
2025-09-27 15:14:48 -06:00
Simon Loir
dd2902a57c fix: fix buildLink in docker compose deploy notifications 2025-09-27 16:50:25 +02:00
Mauricio Siu
0138a7c011 Merge pull request #2532 from monntterro/feat/gitea-http-support
feat: support cloning repositories over HTTP in Gitea integration
2025-09-27 03:17:08 -06:00
autofix-ci[bot]
845d2a3ac5 [autofix.ci] apply automated fixes 2025-09-27 09:15:31 +00:00
Mauricio Siu
4033bb84b2 Merge pull request #2640 from amirparsadd/patch-1
feat: support Arvancloud CDN detection
2025-09-27 03:14:12 -06:00
Mauricio Siu
43e96edcdd Merge pull request #2668 from alsmadi99/canary
feat(scheduler): auto-switch to 'Custom' on manual input
2025-09-27 03:13:00 -06:00
Mauricio Siu
2db388536f Merge pull request #2700 from dennisimoo/compose-alert
feat: add unsaved changes tracking and UI indication
2025-09-27 03:09:33 -06:00
Mauricio Siu
43876efc79 Merge pull request #2677 from dennisimoo/fix-position
style: move Deployments tab after Domains tab
2025-09-27 03:07:02 -06:00
Mauricio Siu
e7c7545c02 Merge pull request #2706 from Dokploy/2673-bitbucket-deployments-are-broken-auth-token-wont-work
fix(bitbucket): enhance Bitbucket authentication handling
2025-09-27 02:58:49 -06:00
autofix-ci[bot]
77705381cd [autofix.ci] apply automated fixes 2025-09-27 08:56:28 +00:00
Mauricio Siu
5fdf82a27f refactor(bitbucket): remove debug console logs from repository cloning process
- Removed console logs for clone URL and repository information to clean up the output during the cloning process.
2025-09-27 02:55:42 -06:00
Mauricio Siu
6bd5b1f71f fix(bitbucket): enhance Bitbucket authentication handling
- Added support for Bitbucket email and workspace name in the authentication process.
- Updated the clone URL generation to use the correct format for API tokens.
- Improved error handling to ensure required fields are provided for both API tokens and app passwords.
- Added console logs for debugging clone URL and repository information during cloning.
2025-09-27 02:55:06 -06:00
Mauricio Siu
17d6830b66 Merge pull request #2705 from Dokploy/2670-bug-deployments-are-mark-as-running-when-they-never-ended-vps-shutdown
2670 bug deployments are mark as running when they never ended vps shutdown
2025-09-27 02:23:53 -06:00
Mauricio Siu
a845eba320 Merge pull request #2696 from Harikrishnan1367709/Most-services-has-no-effect-#2691
Feat: "Most services" sorting to count total services across environments -2691
2025-09-27 02:22:58 -06:00
Mauricio Siu
2f4ec9f35f fix(deployment): reintroduce deployment cancellation during server initialization
- Added the call to initCancelDeployments back into the server initialization process to ensure that deployment cancellations are handled correctly in all environments.
2025-09-27 02:21:02 -06:00
autofix-ci[bot]
b725861b55 [autofix.ci] apply automated fixes 2025-09-27 08:20:36 +00:00
Mauricio Siu
6fa8f63277 fix(deployment): correct deployment cancellation logic and ensure proper status update
- Updated the initCancelDeployments function to set the status of running deployments to 'cancelled' instead of 'error'.
- Reintroduced the call to initCancelDeployments in the server initialization process to ensure cancellations are handled correctly.
2025-09-27 02:20:07 -06:00
Mauricio Siu
ac6bdf60ec feat(deployment): add 'cancelled' status to deployment and implement cancellation logic
- Updated the deployment status enum to include 'cancelled'.
- Added a new utility function to handle the cancellation of deployments, setting their status to 'error'.
- Enhanced the status tooltip component to display 'Cancelled' when the status is 'cancelled'.
- Created a new SQL migration to add the 'cancelled' value to the deploymentStatus type.
2025-09-27 02:15:43 -06:00
randomperson12344
db292e6949 feat: add unsaved changes tracking and UI indication 2025-09-26 20:13:09 -07:00
montero
085f6bbbb7 refactor(gitea): extract clone URL construction into a reusable function 2025-09-26 22:01:54 +03:00
autofix-ci[bot]
cbdc4e4a20 [autofix.ci] apply automated fixes 2025-09-26 08:48:23 +00:00
HarikrishnanD
ee3ff18feb fix: correct "Most services" sorting to count total services across environments - Fix sorting logic to count actual services instead of environment count - Projects now properly sort by total service count in descending order - Resolves issue where "Most services" showed ascending order instead of descending -#2691 2025-09-26 14:15:58 +05:30
autofix-ci[bot]
598ecb8c6e [autofix.ci] apply automated fixes 2025-09-25 06:39:08 +00:00
Sofien Scholze
1d5a523b9e fix: docker terminal dropdown not showing containers for application of type "docker-compose" 2025-09-24 22:52:20 +02:00
Lucas Manchine
4bced9ede0 fix: db migrations for stop grace period swarm 2025-09-24 12:00:09 -03:00
Lucas Manchine
e35aeef4e2 fix: db migrations for stop grace period swarm 2025-09-24 11:53:02 -03:00
Lucas Manchine
5e89ffbf4f fix: extend-database-schemas-with-stopgraceperiodswarm 2025-09-24 10:50:04 -03:00
Lucas Manchine
21de6bf167 test: add missing test 2025-09-24 10:26:36 -03:00
Lucas Manchine
291edce62f fixing migration 2025-09-24 10:02:15 -03:00
Lucas Manchine
59be1c5941 fix: coerce-stopgraceperiodswarm-to-number 2025-09-24 09:54:54 -03:00
Lucas Manchine
2141e4b174 Merge branch 'canary' into feature/stop-grace-period-2227 2025-09-24 08:52:32 -03:00
randomperson12344
df0fb340ad feat: add file upload support for custom profile pictures 2025-09-23 22:32:32 -07:00
randomperson12344
190ccfa91f style: replace generic icons with Gotify and Ntfy brand logos 2025-09-23 21:04:55 -07:00
randomperson12344
f5084dd5fb feat(ui): move Deployments tab to position 4 after Domains tab 2025-09-23 19:23:43 -07:00
autofix-ci[bot]
1b603d84d7 [autofix.ci] apply automated fixes 2025-09-22 19:11:08 +00:00
Mohammad Alsmadi
cf2c89d136 feat(scheduler): auto-switch to 'Custom' on manual input 2025-09-22 13:35:52 +04:00
Amirparsa Baghdadi
95de98e94d close string 2025-09-22 12:37:21 +03:30
Mauricio Siu
569d43ae7f Merge pull request #2525 from divaltor/bitbucket-api-token
feat(bitbucket): Deprecate App password and replace it with API token
2025-09-21 15:18:40 -06:00
Mauricio Siu
d22ed9b569 refactor(bitbucket): streamline extractCommitedPaths function by passing Bitbucket object directly 2025-09-21 15:15:21 -06:00
Mauricio Siu
8b88c85b37 refactor(bitbucket): simplify extractCommitedPaths function by using Bitbucket type and centralized header generation 2025-09-21 15:14:15 -06:00
Mauricio Siu
11fbd047d0 feat(bitbucket): enhance API token creation instructions in Bitbucket provider settings 2025-09-21 14:13:55 -06:00
Mauricio Siu
69af9c0312 refactor(gitea): update repository and branch fetching to use pagination with /user/repos and /branches endpoints 2025-09-21 14:10:00 -06:00
Mauricio Siu
063d51e442 feat(bitbucket): add bitbucketEmail field to Bitbucket provider settings and update related API and database schema 2025-09-21 13:54:53 -06:00
Mauricio Siu
0a789e1d6f feat(bitbucket): add apiToken column to the Bitbucket table and update migration journal with new entry for 0111_mushy_wolfsbane 2025-09-21 03:10:52 -06:00
Mauricio Siu
671cd497fd Merge branch 'canary' into bitbucket-api-token 2025-09-21 03:10:37 -06:00
Mauricio Siu
8ddc254252 chore: remove '0110_smiling_slapstick' migration and associated journal entry 2025-09-21 03:10:30 -06:00
Mauricio Siu
2668e22302 feat(bitbucket): add apiToken column to Bitbucket table and update migration journal 2025-09-21 03:09:32 -06:00
Mauricio Siu
37145fbdf2 chore: bump version to v0.25.3 in package.json 2025-09-21 03:02:43 -06:00
Mauricio Siu
6847d8dbef Merge pull request #2516 from cheetahbyte/fix/special-characters-passwords
fix(registries): special character passwords not working in registry creation.
2025-09-21 02:45:43 -06:00
Mauricio Siu
032bcb7459 Merge pull request #2657 from Dokploy/2529-renaming-a-git-provider-wont-update-the-external-link-url
feat: add appName field to GitHub provider settings and update relate…
2025-09-21 02:41:11 -06:00
Mauricio Siu
68be7a259f Merge pull request #2656 from Dokploy/2533-unknown-tag-reset-error-in-domains-when-extending-docker-compose-configuration
refactor: replace js-yaml with yaml package for YAML parsing and stri…
2025-09-21 02:40:40 -06:00
Mauricio Siu
7d682870ff feat: add appName field to GitHub provider settings and update related API and database schema 2025-09-21 02:39:20 -06:00
Mauricio Siu
d1a1a80c77 refactor: further standardize YAML parsing in test files by replacing load with parse 2025-09-21 02:38:16 -06:00
Mauricio Siu
3d7dc82232 refactor: update test files to consistently use parse function for YAML parsing 2025-09-21 02:35:36 -06:00
Mauricio Siu
fedc88eb40 refactor: consistently replace load function with parse for YAML parsing in all test files 2025-09-21 02:28:30 -06:00
Mauricio Siu
5d0f6a4657 refactor: replace load function with parse for YAML parsing in test files 2025-09-21 02:27:16 -06:00
Mauricio Siu
4718461405 refactor: update YAML parsing from js-yaml to yaml package in test files 2025-09-21 02:24:41 -06:00
Mauricio Siu
80b22d9458 refactor: replace js-yaml with yaml package for YAML parsing and stringifying across the application 2025-09-21 02:20:20 -06:00
Mauricio Siu
8fa5fe7f2c Merge pull request #2654 from Dokploy/2018-traefik-never-start-error-read-etctraefiktraefikyml-is-a-directory
2018 traefik never start error read etctraefiktraefikyml is a directory
2025-09-21 01:39:06 -06:00
Mauricio Siu
4ced8bec96 feat: add completion message and exit process after Dokploy setup 2025-09-21 01:35:46 -06:00
Mauricio Siu
9ecb770a01 fix: enhance Traefik setup by adding directory checks and cleanup for existing config files 2025-09-21 01:31:21 -06:00
Mauricio Siu
8ac586b2f7 Merge pull request #2653 from Dokploy/2554-ai-assistant-is-broken-in-v025
fix: handle optional configFiles in template details and improve mapp…
2025-09-21 01:11:27 -06:00
Mauricio Siu
0a1800ba6d fix: adjust layout by removing unnecessary flex class from password input container 2025-09-21 01:09:12 -06:00
Mauricio Siu
f13028ee70 fix: handle optional configFiles in template details and improve mapping safety 2025-09-21 01:07:25 -06:00
Mauricio Siu
b6b6b9f2ce Merge pull request #2652 from Dokploy/2630-backups-dont-get-deleted-when-backup-fails
fix: enhance error handling in volume backup process by adding cleanu…
2025-09-21 00:27:45 -06:00
Mauricio Siu
f46637b8e1 fix: enhance error handling in volume backup process by adding cleanup for .tar files 2025-09-21 00:26:48 -06:00
Mauricio Siu
948ed2cc0d fix: improve registry tag construction to conditionally include registry URL 2025-09-21 00:13:56 -06:00
Mauricio Siu
a536c977f0 Merge pull request #2651 from Dokploy/2633-error-parsing-reference-app-aaa-9bkzznlatest-is-not-a-valid-repositorytag-invalid-reference-format
fix: update registry tag construction to handle optional registry URL
2025-09-21 00:11:21 -06:00
Mauricio Siu
8524cd0972 fix: update registry tag construction to handle optional registry URL 2025-09-21 00:09:19 -06:00
Mauricio Siu
ac1e51cd11 Merge pull request #2650 from Dokploy/2638-overlay-network-not-working-across-nodes
refactor: replace getPublicIpWithFallback with getLocalServerIp for i…
2025-09-21 00:01:28 -06:00
Mauricio Siu
ca243d7259 refactor: replace getPublicIpWithFallback with getLocalServerIp for improved local IP retrieval 2025-09-20 23:57:38 -06:00
Mauricio Siu
e1ce54c159 Merge pull request #2622 from Harikrishnan1367709/Compose-does-not-display-the-domain-under-dashboard/projects-#2606
fix: Display Compose service domains in projects dashboard (#2606)
2025-09-20 16:37:49 -06:00
Mauricio Siu
031302d808 Merge pull request #2643 from nimone/patch-1
fix: prevent the shrinking of icon button for view mode on add template dialog
2025-09-20 16:36:57 -06:00
Mauricio Siu
5e01505e4d fix: update input class for better responsiveness in add template component 2025-09-20 16:36:36 -06:00
Mauricio Siu
c423724972 Merge pull request #2614 from Harikrishnan1367709/Profile-email-field-accepts-empty-values-causing-sign-in-issues-#2613
Fix profile email validation to prevent empty values causing sign-in issues-#2613
2025-09-20 16:34:20 -06:00
Mauricio Siu
f1f7639708 Merge pull request #2624 from dragospaulpop/dragospaulpop-patch-cloneRawGitlabRepositoryRemote
Fix: Update gitlab.ts cloneRawGitlabRepositoryRemote to use gitlabBranch
2025-09-20 16:32:19 -06:00
Mauricio Siu
9ef1a76a85 Merge pull request #2582 from yigitahmetsahin/feat/improve-db-backups
feat(backups): make mariadb backups non-blocking
2025-09-20 16:31:16 -06:00
Nishant Mogha
30b66a4828 fix: prevent shrinking icon button for view mode on add template 2025-09-19 21:13:20 +05:30
Amirparsa Baghdadi
4416ca9cd2 Add arvancloud to CDNs 2025-09-19 15:58:22 +03:30
Dragos-Paul Pop
f2ead66890 Update gitlab.ts cloneRawGitlabRepositoryRemote to use gitlabBranch
Cloning a GitLab repository for a compose service to a remote server incorrectly used the "branch" column from Postgres' "compose" table instead of the "gitlabBranch" column causing an error.
2025-09-17 11:48:12 +03:00
HarikrishnanD
64475bbb13 fix: Compose domain display logic in projects dashboard - Uncommented the commented-out Compose domain rendering code in ShowProjects.tsx - Fixed data structure to properly iterate through project.environments and env.compose - Added proper condition checking for compose services - Compose services now display their domains in the projects dashboard dropdown - Resolves issue #2606 where template-deployed Compose services didn't show domains 2025-09-17 14:07:03 +05:30
autofix-ci[bot]
c1896f8877 [autofix.ci] apply automated fixes 2025-09-16 07:47:55 +00:00
HarikrishnanD
d13975adac fix: add email validation to profile form to prevent empty values - Add email format and required validation to profile form schema - Add email validation to API schema and service layer - Improve error handling in user update mutation - Fixes issue where users could save empty email causing sign-in failures -#2613 2025-09-16 13:11:22 +05:30
Mauricio Siu
d9398b9558 feat(workers): add third worker and increase concurrency for existing workers 2025-09-15 23:43:27 -06:00
Mauricio Siu
788dbe4050 chore(package): bump version from v0.25.1 to v0.25.2 2025-09-15 23:23:03 -06:00
Mauricio Siu
6934f44778 Merge pull request #2573 from Harikrishnan1367709/Duplicating-a-service-does-not-refresh-the-list-afterwards-#2565-Harikrishnan
feat: Auto-refresh services list when duplicating to same environment
2025-09-15 23:18:40 -06:00
Mauricio Siu
b8e9602538 feat(bitbucket): update Bitbucket token management and add API token column to database 2025-09-15 23:10:50 -06:00
Mauricio Siu
afca968853 chore: remove unused migration and associated journal entry for '0110_dry_golden_guardian' 2025-09-15 23:03:43 -06:00
Mauricio Siu
457a6db00f Merge pull request #2562 from sundakai/canary
fix:traefik 3.5.0 error
2025-09-15 22:59:11 -06:00
Mauricio Siu
81f89a0796 Merge pull request #2597 from demondayza/canary
fix: fix typo for Github clone
2025-09-15 22:27:00 -06:00
autofix-ci[bot]
65c5974b4f [autofix.ci] apply automated fixes 2025-09-12 13:49:51 +00:00
autofix-ci[bot]
bdf0a932fe [autofix.ci] apply automated fixes 2025-09-12 13:46:33 +00:00
HarikrishnanD
c355eafc95 feat: add environment deletion permission control - Add canDeleteEnvironments field to member table - Implement permission validation in environment deletion endpoint - Add UI toggle in user permissions modal - Hide delete buttons for users without permission - Maintain backward compatibility for owners/admins #2594 2025-09-12 19:09:30 +05:30
Andrew Margetts
d8a98f3936 fix: fix typo for Github clone 2025-09-12 15:27:10 +02:00
HarikrishnanD
30b28afbac feat: add canCreateEnvironments permission for environment creation - Add database field and API validation - Implement permission checking in environment creation - Add UI toggle in user permissions modal - Hide create button for unauthorized users Fixes #2593 2025-09-12 17:56:02 +05:30
Yigit SAHIN
c9715b19a3 feat(backups): make mariadb backups non-blocking closes #2443 2025-09-10 11:27:22 +03:00
Rob Graeber
1a940580ae Fix swarm settings config placeholders 2025-09-09 18:03:02 -06:00
autofix-ci[bot]
ec11325165 [autofix.ci] apply automated fixes 2025-09-09 16:40:00 +00:00
HarikrishnanD
abcbd2d599 feat: auto-refresh services list when duplicating to same environment - Add cache invalidation for environment.one and environment.byProjectId queries - Fix issue where duplicated services weren't visible until hard refresh - Ensure proper invalidation when duplicating to current environment - Resolves #2565 2025-09-09 22:07:40 +05:30
永恒
1664ae9b92 fix traefik 3.5.0 error
fix traefik error:"both Docker and Swarm labels are defined"
2025-09-08 12:26:36 +08:00
Mauricio Siu
24729f35ec fix(traefik): remove error toast on dashboard action failure 2025-09-07 14:03:10 -06:00
Mauricio Siu
3eaeaa1db4 chore(package): bump version from v0.25.0 to v0.25.1 2025-09-07 13:31:38 -06:00
Mauricio Siu
de4a00f1e9 Merge pull request #2556 from Dokploy/2552-traefik-container-no-auto-start
feat(settings): add error handling for unsupported resource types in …
2025-09-07 13:31:03 -06:00
Mauricio Siu
2f5cd620c5 feat(settings): add error handling for unsupported resource types in Traefik setup
- Introduced error handling for unsupported resource types in `readPorts` and `writeTraefikSetup` functions.
- Enhanced `initializeStandaloneTraefik` to include image pulling with error logging for better debugging.
2025-09-07 13:26:19 -06:00
Mauricio Siu
1763000070 Merge pull request #2545 from Dokploy/feat/clean-build-queue-on-build
feat(deployment): add cancellation functionality queue for deployments
2025-09-06 22:12:44 -06:00
Mauricio Siu
3519913886 fix(deployment): update stuck build notification time from 9 to 10 minutes 2025-09-06 22:09:48 -06:00
Mauricio Siu
63e578f13c refactor(deployment): update cancellation input schemas for applications and composes
- Removed the previous cancellation schemas for deployments.
- Replaced them with a unified input schema for finding applications and composes during cancellation requests.
- Ensured that the cancellation logic now utilizes the new input structure for better consistency.
2025-09-06 22:08:59 -06:00
autofix-ci[bot]
d80ada7c00 [autofix.ci] apply automated fixes 2025-09-07 04:06:08 +00:00
Mauricio Siu
766cd20e90 feat(deployment): improve stuck deployment detection and update status
- Enhanced the stuck deployment check to only consider the most recent deployment.
- Updated the logic to correctly identify if the most recent deployment has been running for more than 9 minutes.
- Added functionality to update the deployment status to "done" upon application and compose cancellation.
2025-09-06 22:05:39 -06:00
Mauricio Siu
4e69c70697 feat(deployment): add cancellation functionality for deployments
- Introduced a new endpoint for cancelling deployments, allowing users to cancel both application and compose deployments.
- Implemented validation schemas for cancellation requests.
- Enhanced the deployment dashboard to provide a cancellation option for stuck deployments.
- Updated server-side logic to handle cancellation requests and send appropriate events.
2025-09-06 21:53:15 -06:00
Mauricio Siu
3b7d009841 fix(search-command): remove console log for project debugging 2025-09-06 20:36:01 -06:00
Mauricio Siu
b4e29dab39 Merge pull request #2515 from divaltor/filter-projects-shortcut
feat(input): Add focus by Cmd + K shortcut to search input
2025-09-06 14:32:52 -06:00
Mauricio Siu
090ec2b3b9 Merge pull request #2540 from robgraeber/canary
fix: typo and improve grammar
2025-09-06 14:32:41 -06:00
Mauricio Siu
57dc24bcb1 feat(search-command): enhance service extraction and project navigation
- Introduced a new function `extractAllServicesFromProject` to aggregate services from all environments within a project, including environment details.
- Updated the project selection logic to navigate to the production environment of a project.
- Modified the display of services to include the environment name alongside the service name in the search results.
2025-09-06 14:30:59 -06:00
Mauricio Siu
f630b889c6 Merge branch 'canary' into filter-projects-shortcut 2025-09-06 14:19:35 -06:00
Rob Graeber
a2abb205fd fix: typo and improve grammar 2025-09-06 13:19:13 -07:00
Mauricio Siu
1f2dabb16b Merge pull request #2429 from CatPaulKatze/feat/ntfy
feat(notification): add ntfy notifications
2025-09-06 14:17:27 -06:00
Mauricio Siu
ffb69fedff feat: Add 'ntfy' notification type and related database schema changes
- Introduced a new notification type 'ntfy' to the public.notificationType enum.
- Created a new table 'ntfy' with fields for notification ID, server URL, topic, access token, and priority.
- Updated the existing 'notification' table to include a foreign key reference to the new 'ntfy' table.
2025-09-06 14:13:47 -06:00
Mauricio Siu
fbc087bd84 Merge branch 'canary' into feat/ntfy 2025-09-06 14:12:06 -06:00
Mauricio Siu
ccb995cb7d chore: remove SQL files and journal entries for 'bitter_starfox' and 'needy_rocket_raccoon' 2025-09-06 14:11:39 -06:00
Mauricio Siu
02685fde9d Merge pull request #2507 from Harikrishnan1367709/Allow-setting-a-title/description-for-deployments-via-API-or-CLI-#1485-Harikrishnan
feat: Add custom title/description support for API/CLI deployments (#1485)
2025-09-06 14:00:26 -06:00
autofix-ci[bot]
fc2bd44983 [autofix.ci] apply automated fixes 2025-09-06 19:49:09 +00:00
Mauricio Siu
30a2d78a5b Merge pull request #2502 from Harikrishnan1367709/Issue-1852-Harikrishnan
feat: Add default "Dokploy" option to server selection dropdown (#1852)
2025-09-06 13:47:42 -06:00
autofix-ci[bot]
081ba60f6e [autofix.ci] apply automated fixes 2025-09-06 19:36:11 +00:00
Lucas Manchine
b7e2df6d6a refactor: clean up stopGracePeriodSwarm assignment formatting │
│                                                                                                                                                                                                                                                                                                                                                                                                    │
│   - Improve code readability by condensing multi-line assignment                                                                                                                                                                                                                                                                                                                                   │
│   - Maintain consistent formatting with other field assignments                                                                                                                                                                                                                                                                                                                                    │
│   - No functional changes, formatting only
2025-09-05 15:34:03 -03:00
Lucas Manchine
85e3a92877 feat(swarm): add stop grace period configuration for Docker Swarm services
- Add stopGracePeriodSwarm field to application schema for configuring container shutdown grace period
- Update swarm settings UI to include nanosecond input for stop grace period
- Regenerate migration as 0110 to resolve sequence conflict with canary branch
- Clean up commented debug code and reorganize imports

The stop grace period allows users to specify how long Docker should wait before forcefully
terminating a container during shutdown, improving graceful shutdown handling for applications.
2025-09-05 13:23:46 -03:00
Lucas Manchine
c2eaa78724 refactor: clean up stopGracePeriodSwarm implementation │
│                                                                                                                                                                                                                                                                                                                               │
│   - Remove commented debug code                                                                                                                                                                                                                                                                                               │
│   - Reorganize imports for better readability
2025-09-05 13:05:46 -03:00
Lucas Manchine
270b4d4edc Merge branch 'canary' into feature/stop-grace-period-2227-alt 2025-09-05 12:34:17 -03:00
Vlad Vladov
2d41db7f37 feat(input): Replace Input with FocusShortcutInput 2025-09-05 18:19:16 +03:00
Vlad Vladov
d0f54f2067 feat(input): Add focus by Cmd + K shortcut to search input 2025-09-05 18:13:23 +03:00
Vlad Vladov
a6ca41f91f feat(bitbucket): Re-generate migration 2025-09-05 18:09:18 +03:00
Vlad Vladov
b2b649c5cd refactor(bitbucket): Extract duplicated code to a function 2025-09-05 18:08:37 +03:00
autofix-ci[bot]
225c398d31 [autofix.ci] apply automated fixes 2025-09-05 18:08:37 +03:00
Vlad Vladov
07b99bd4e4 style(ui): Remove tooltip 2025-09-05 18:08:37 +03:00
autofix-ci[bot]
652e8910f4 [autofix.ci] apply automated fixes 2025-09-05 18:08:36 +03:00
Vlad Vladov
e04e25385d feat(bitbucket): Deprecate App password and replace it with API token 2025-09-05 18:08:35 +03:00
Lucas Manchine
da9df3e239 testing changes 2025-09-05 11:49:32 -03:00
Paul Sommer
6833713697 perf: remove unnecessary decoration boolean on the ntfy database schema 2025-09-05 11:35:28 +02:00
Mauricio Siu
d0489f6e11 Merge branch 'canary' into Issue-1852-Harikrishnan 2025-09-05 03:12:01 -06:00
Mauricio Siu
39872720dd refactor: remove debug logging from Docker resource type determination functions 2025-09-05 03:00:57 -06:00
Mauricio Siu
b90f0135d4 refactor: simplify Docker resource type determination logic by consolidating command structure 2025-09-05 02:50:37 -06:00
Mauricio Siu
35fc04dc8f feat: enhance error handling and logging in Docker resource type determination 2025-09-05 02:35:06 -06:00
Mauricio Siu
c6509efa65 feat: add debug logging for resource name and command in Docker resource type determination 2025-09-05 02:11:46 -06:00
Mauricio Siu
3891798b17 Merge pull request #2527 from Dokploy/fix/connect-network-after-creation-remote-servers
Fix/connect network after creation remote servers
2025-09-05 01:52:54 -06:00
Mauricio Siu
3662c1a684 fix: change Traefik container restart policy to 'always' and ensure it connects to the dokploy network 2025-09-05 01:49:47 -06:00
Mauricio Siu
d96e9071f2 feat: add logging for resource type determination and error handling in Docker resource management 2025-09-05 01:47:12 -06:00
Mauricio Siu
e637a4ad99 Merge pull request #2526 from Dokploy/2480-backup-process-exposes-s3-credentials-in-logs
feat: add validation to prevent use of 'production' as environment na…
2025-09-05 01:16:28 -06:00
Mauricio Siu
1ce15da7ce feat: add validation to prevent use of 'production' as environment name in creation and update operations, enhancing error handling in environment management 2025-09-05 01:14:44 -06:00
Mauricio Siu
0dca1b2216 Merge pull request #2489 from typed-sigterm/patch-2
fix: print error when docker build fails
2025-09-05 01:08:11 -06:00
Mauricio Siu
c73a14a379 Merge branch 'canary' into patch-2 2025-09-05 01:07:51 -06:00
Mauricio Siu
392e3434c4 refactor: make database root password optional in schema and mutation logic, enhancing flexibility in database configuration 2025-09-05 01:01:26 -06:00
Mauricio Siu
e3f3426f1c refactor: remove redundant password requirement validation from database schemas, improving consistency across database configurations 2025-09-05 01:00:18 -06:00
Mauricio Siu
a09cd06eea refactor: streamline conditional rendering for service creation dropdown in EnvironmentPage, improving code readability and maintainability 2025-09-05 00:56:37 -06:00
Mauricio Siu
87a41ca710 Merge pull request #2499 from Dokploy/324-environmentfoldergroup-features-on-projects
324 environmentfoldergroup features on projects
2025-09-05 00:25:34 -06:00
Mauricio Siu
35b7b5bd68 feat: implement environment access control and service filtering based on user permissions, enhancing security and usability in environment management 2025-09-05 00:23:01 -06:00
Mauricio Siu
16c37c3ceb feat: add accessedEnvironments field to user and member schemas, enhancing permission management for environment access 2025-09-05 00:13:04 -06:00
Mauricio Siu
42548f310e refactor: simplify project selection logic in EnvironmentPage by removing unnecessary filters, improving readability and performance 2025-09-04 23:50:10 -06:00
Mauricio Siu
47b66d0dc3 refactor: enhance access control in environment, mount, port, rollback, and schedule routers to ensure users can only interact with resources belonging to their organization 2025-09-04 23:32:25 -06:00
HarikrishnanD
32cbc5b4b7 feat: Add custom title/description for deployments via API/CLI - Add optional title/description fields to deployment schemas - Update TRPC and external API endpoints - Replace generic "Manual deployment" with custom titles - Maintain backward compatibility with default values Fixes #1485 2025-09-04 19:12:29 +05:30
Typed SIGTERM
15171622df fix 2025-09-04 20:08:50 +08:00
HarikrishnanD
46f1af3bb3 feat(ui): add conditional server dropdown with Dokploy default option - Add IS_CLOUD flag support for server selection dropdown - Show "Dokploy" as default option in self-hosted environments - Hide dropdown when no remote servers exist - Add conditional placeholder text and server count display - Handle "dokploy" value in form submission (converts to undefined) - Apply changes to all relevant components: add-application, add-compose, add-template, add-database, add-certificate, and AI step-one Resolves #1852 2025-09-04 13:54:19 +05:30
Mauricio Siu
d199a54033 refactor: update environment invalidation logic in AdvancedEnvironmentSelector to use byProjectId, improving data consistency and clarity 2025-09-03 23:56:31 -06:00
Mauricio Siu
fb749cd862 feat: implement comprehensive environment variable resolution in preparation functions, enhancing flexibility and support for nested references across services and environments 2025-09-03 21:41:11 -06:00
Mauricio Siu
4c5771b55b feat: add EnvironmentVariables component for managing environment variables, enhancing project configuration capabilities 2025-09-03 21:24:59 -06:00
Mauricio Siu
7e1de62ab1 refactor: enhance environment selector component and database schema to support new environment field, improving clarity and functionality in project management 2025-09-03 21:19:12 -06:00
Mauricio Siu
d67644e52f refactor: adjust environment page to correctly display project name and reintroduce duplicate project functionality, enhancing user navigation and clarity 2025-09-03 21:11:54 -06:00
Mauricio Siu
52e21dab4e refactor(ui): simplify server selection logic across components - Remove redundant server count check in server selection dropdowns across multiple components (AddApplication, AddCompose, AddDatabase, AddTemplate, StepOne, AddCertificate) to streamline UI behavior. 2025-09-03 20:45:47 -06:00
autofix-ci[bot]
4a3a7fa47b [autofix.ci] apply automated fixes 2025-09-04 02:43:53 +00:00
autofix-ci[bot]
68945c6888 [autofix.ci] apply automated fixes 2025-09-03 18:17:04 +00:00
Leonhard Breuer
146d82b6c4 feat: use printf instead of echo 2025-09-03 20:12:16 +02:00
Leonhard Breuer
02215d4e21 fix: use new command for registry updates 2025-09-03 19:59:17 +02:00
Leonhard Breuer
4ca05414af fix: use shellsafe docker command
- add `shEscape` function - add `safeDockerLoginCommand` - use the new
functions to contruct better registry login command
2025-09-03 19:52:01 +02:00
Mauricio Siu
aa7e382818 feat(readme): add sponsorship section for Tuple with logo 2025-09-03 03:00:48 -06:00
Mauricio Siu
87a9ed46ba refactor: update service extraction logic to utilize environment data, enhancing clarity and consistency in monitoring setup 2025-09-03 02:58:38 -06:00
HarikrishnanD
90d9880301 feat: add custom title/description support for API/CLI deployments - Add optional title and description fields to deployment schemas - Update TRPC endpoints to accept custom deployment titles/descriptions - Update external API to support custom deployment metadata - Maintain backward compatibility with existing deployments - Resolves issue #1485: Allow setting title/description for deployments via API/CLI 2025-09-03 09:05:33 +05:30
HarikrishnanD
940b9967b8 feat(ui): add default "Dokploy" option to server selection dropdown - Add "Dokploy" as default option in server selection dropdowns - Hide dropdown when only one server is available (servers.length <= 1) - Show dropdown only when multiple servers exist (servers.length > 1) - Update placeholder text from "Select a Server" to "Dokploy" - Fix issue where users couldn't switch back to default server - Update form submission logic to handle "dokploy" default value - Apply changes to all deployment components (application, compose, template, database, certificate, AI) Resolves #1852 2025-09-02 19:17:46 +05:30
Mauricio Siu
741085466b refactor: remove projectId references from service components, streamlining navigation and enhancing clarity in environment context 2025-09-02 00:25:09 -06:00
Mauricio Siu
11b0e21728 refactor: replace projectId with environmentId in database schema, enhancing clarity and consistency in environment management across services 2025-09-02 00:18:36 -06:00
autofix-ci[bot]
990b174110 [autofix.ci] apply automated fixes 2025-09-02 05:24:22 +00:00
Mauricio Siu
4c4c72bc9c refactor: update permissions handling to extract services from environments, improving data structure and clarity in user permissions management 2025-09-01 23:23:58 -06:00
autofix-ci[bot]
8f446d04f3 [autofix.ci] apply automated fixes 2025-09-02 05:20:20 +00:00
Mauricio Siu
e8a5f9c0a8 refactor: restructure application and rollback context to encapsulate project within environment, improving data organization and clarity across services 2025-09-01 23:19:53 -06:00
autofix-ci[bot]
c57c231c32 [autofix.ci] apply automated fixes 2025-09-02 05:16:09 +00:00
Mauricio Siu
8194929558 refactor: improve project navigation logic by ensuring proper handling of projectId and environmentId, enhancing routing clarity and user experience 2025-09-01 23:15:44 -06:00
autofix-ci[bot]
4a07118acd [autofix.ci] apply automated fixes 2025-09-02 05:10:56 +00:00
Mauricio Siu
be9e19e708 refactor: enhance project and environment handling across components and services by replacing projectId with environmentId, improving context clarity and authorization checks 2025-09-01 23:10:37 -06:00
Mauricio Siu
3e7eff11cd refactor: update application deployment logic to utilize environment context for project name and organization ID, enhancing clarity and consistency across services 2025-09-01 22:51:35 -06:00
Mauricio Siu
f8ebf77575 Merge pull request #2493 from nktnet1/fix-server-schedule-responsiveness
fix(ui): schedule responsiveness
2025-09-01 22:46:40 -06:00
Mauricio Siu
de3c845ab0 refactor: update duplicate project logic to use 'existing-environment' for improved clarity in project duplication context 2025-09-01 22:45:57 -06:00
autofix-ci[bot]
cb992259cf [autofix.ci] apply automated fixes 2025-09-02 04:42:24 +00:00
Mauricio Siu
883c3f9739 refactor: update DuplicateProject and AdvancedEnvironmentSelector components to utilize environmentId for improved context handling; enhance UI with project and environment selection features for better user experience 2025-09-01 22:40:51 -06:00
Mauricio Siu
766890192d refactor: streamline environment selector by utilizing findEnvironmentById for type definition; enhance service presence checks and UI layout for improved clarity 2025-09-01 21:33:13 -06:00
Mauricio Siu
1a9f131d39 refactor: enhance environment selector with service presence checks and alert notifications; update navigation links to include environment context for improved user experience 2025-09-01 21:18:56 -06:00
Mauricio Siu
59cbc8ee0d refactor: update environment selector and API routes to utilize environmentId for service management; enhance UI with Badge component for production environments 2025-09-01 21:09:30 -06:00
Mauricio Siu
e9322fc900 refactor: add environment name links to service components for improved navigation and context clarity 2025-09-01 20:58:22 -06:00
Mauricio Siu
39d48d8bdf refactor: update API and dashboard components to replace projectId with environmentId for improved context handling and authorization checks 2025-09-01 20:39:58 -06:00
Mauricio Siu
399bcb0302 refactor: update project and API components to utilize environment context for organization authorization checks and enhance service retrieval methods 2025-09-01 20:36:03 -06:00
Mauricio Siu
e0b6a8627a refactor: update database service components to utilize environment context for project name and organization authorization checks 2025-09-01 20:15:05 -06:00
Mauricio Siu
ecf7ae924f refactor: update routing in dashboard components to include environment context; add new service pages for MongoDB, MySQL, PostgreSQL, Redis, and MariaDB 2025-09-01 20:12:14 -06:00
Mauricio Siu
d57a0cf439 refactor: update API routes and services to use environment context for organization authorization checks; enhance service retrieval methods to include environment details 2025-09-01 20:05:36 -06:00
Mauricio Siu
52d2bd2114 refactor: remove EnvironmentManagement component and related environment selector from project dashboard; update environment page to use Badge component for production label 2025-09-01 19:52:30 -06:00
Mauricio Siu
72f8a28f4f refactor: update project structure to use environmentId instead of projectId across components and API routes; implement environment management features 2025-09-01 19:48:20 -06:00
Mauricio Siu
6fc325fe95 feat(environment): implement environment management with create, duplicate, and delete functionalities; add environment schema and database migrations 2025-09-01 17:36:27 -06:00
Mauricio Siu
fd199fdcc0 Merge pull request #2498 from Dokploy/2456-cannot-back-up-mariadb-database-access-denied-error
feat(database): enhance password validation for database schemas and …
2025-09-01 16:21:22 -06:00
Mauricio Siu
5e1a164a54 chore(pr-template): streamline checklist formatting and clarify issue closing instructions 2025-09-01 16:19:24 -06:00
Mauricio Siu
bc2b4f1369 feat(database): enhance password validation for database schemas and update input components for password visibility 2025-09-01 16:16:55 -06:00
Tam Nguyen
38abe03257 fix(ui): flex-wrap on schedule name and enabled 2025-08-31 10:36:07 +10:00
autofix-ci[bot]
22e40134ea [autofix.ci] apply automated fixes 2025-08-31 00:30:08 +00:00
Tam Nguyen
a2841fdd30 fix(ui): flex-wrap for cron and shell type 2025-08-31 10:27:12 +10:00
Tam Nguyen
468feaa092 fix(ui): improve server schedule responsiveness for mobile 2025-08-31 10:25:09 +10:00
Typed SIGTERM
caf244120c fix: print error when docker build fails 2025-08-30 13:41:40 +08:00
Mauricio Siu
7273c636a0 Merge pull request #2461 from Dokploy/fix/re-apply-database-migration-fix
Reapply "refactor: update database connection handling and remove unu…
2025-08-28 19:21:28 -06:00
Mauricio Siu
b9a8b27441 feat(notification): add 'ntfy' notification type and create associated table; update notification schema 2025-08-28 19:09:58 -06:00
Mauricio Siu
9f1f13b21b Merge branch 'canary' into feat/ntfy 2025-08-28 19:07:53 -06:00
Mauricio Siu
793a8ba760 chore: remove unused SQL file and related journal entry for 'flimsy_doctor_octopus' 2025-08-28 19:07:44 -06:00
Mauricio Siu
d6a0585bae chore(package): update dokploy version to v0.25.0 2025-08-28 19:03:37 -06:00
Mauricio Siu
935d1686f2 chore: add new branch for database migration fix in Dokploy workflow 2025-08-28 19:02:21 -06:00
Mauricio Siu
349248105a Merge pull request #2482 from Dokploy/2470-post-rediscreate-returns-true-instead-of-the-redis-payload
fix(redis): return newRedis object instead of true in redis router
2025-08-28 18:43:04 -06:00
Mauricio Siu
d922568510 fix(redis): return newRedis object instead of true in redis router 2025-08-28 18:42:21 -06:00
Mauricio Siu
44ae4df151 fix(settings): change user subscription query to protected procedure 2025-08-28 18:27:47 -06:00
Mauricio Siu
77fdda4c09 Merge pull request #2481 from Dokploy/feat/allow-chatwoot-on-paid-users
feat(settings): add user subscription check to dashboard layout
2025-08-28 18:27:05 -06:00
Mauricio Siu
8a1e36cc3b feat(settings): add user subscription check to dashboard layout 2025-08-28 18:26:05 -06:00
Mauricio Siu
1635bab44f Reapply "refactor: update database connection handling and remove unused migra…"
This reverts commit 17f333ac2a.
2025-08-24 23:49:48 -06:00
Mauricio Siu
4a52459015 Merge pull request #2460 from Dokploy/revert-2459-2234-database-migration-fails-with-password-authentication-failed-when-using-a-custom-postgres_password
Revert "refactor: update database connection handling and remove unused migra…"
2025-08-24 23:44:23 -06:00
Mauricio Siu
17f333ac2a Revert "refactor: update database connection handling and remove unused migra…" 2025-08-24 23:44:00 -06:00
Mauricio Siu
d770307d64 Merge pull request #2459 from Dokploy/2234-database-migration-fails-with-password-authentication-failed-when-using-a-custom-postgres_password
refactor: update database connection handling and remove unused migra…
2025-08-24 23:43:52 -06:00
Mauricio Siu
aa434cbdea feat(db): add database connection setup using drizzle-orm for PostgreSQL 2025-08-24 16:25:04 -06:00
Mauricio Siu
c42054b965 feat(migration): implement database migration functionality using drizzle-orm 2025-08-24 16:22:42 -06:00
Mauricio Siu
03588bf375 chore: remove console.log statement from esbuild configuration 2025-08-24 16:21:01 -06:00
Mauricio Siu
8c420ff4f5 refactor: update package.json to use TypeScript source files instead of compiled JavaScript 2025-08-24 16:20:32 -06:00
Mauricio Siu
cbf6f95891 refactor: update database connection handling and remove unused migration and seed files 2025-08-24 16:19:33 -06:00
Mauricio Siu
2d2a3d74ec Merge pull request #2412 from moosti/feat/two-factor-autofocus
feat: add autofocus to two-factor authentication input
2025-08-24 13:10:30 -06:00
Mauricio Siu
56b9fb531a Merge pull request #2447 from divaltor/volume-backup
feat(volume): Add possibility to keep latest N backups for custom apps
2025-08-24 00:44:27 -06:00
Mauricio Siu
59aaa1a47a fix(ui): adjust max width for volume backup dialog based on backup type 2025-08-24 00:40:17 -06:00
autofix-ci[bot]
5e4444610c [autofix.ci] apply automated fixes 2025-08-24 06:33:36 +00:00
Mauricio Siu
34e6cd87df Merge pull request #2410 from gentslava/fix/ollama-ai-provider
Ollama AI provider
2025-08-24 00:30:59 -06:00
Mauricio Siu
31b13b8d34 Merge pull request #2453 from Dokploy/2452-no-removal-of-preview-deployments-when-they-are-merged
fix: correct application not found error message and improve error ha…
2025-08-23 23:01:03 -06:00
Mauricio Siu
746cf76cf3 fix: correct application not found error message and improve error handling in removePreviewDeployment function 2025-08-23 22:59:52 -06:00
Mauricio Siu
46c53a05bf Merge pull request #2231 from PiquelChips/feat/label-previews
feat: preview deployments for pull requests with specific labels
2025-08-23 20:19:50 -06:00
Mauricio Siu
f97f6d8178 Merge branch 'feat/label-previews' of github.com:PiquelChips/dokploy into feat/label-previews 2025-08-23 20:19:34 -06:00
Mauricio Siu
c653dd604f feat: add previewLabels property to baseApp in drop and traefik test files 2025-08-23 20:19:14 -06:00
autofix-ci[bot]
40877e4370 [autofix.ci] apply automated fixes 2025-08-24 02:16:35 +00:00
Mauricio Siu
65203036f2 Merge branch 'canary' into feat/label-previews 2025-08-23 20:15:37 -06:00
Mauricio Siu
2ef5f967a9 refactor: clean up imports in show-preview-settings component 2025-08-23 20:14:41 -06:00
Mauricio Siu
b20c95ffbc Merge branch 'canary' into feat/label-previews 2025-08-23 20:14:16 -06:00
Mauricio Siu
09b2492585 Merge branch 'feat/label-previews' of github.com:PiquelChips/dokploy into feat/label-previews 2025-08-23 20:13:22 -06:00
Mauricio Siu
ca1fa7c4f7 feat: add support for preview labels in deployment process 2025-08-23 20:11:18 -06:00
autofix-ci[bot]
112b898d98 [autofix.ci] apply automated fixes 2025-08-24 02:01:00 +00:00
Mauricio Siu
8185482bcd Merge pull request #2370 from gentslava/fix/traefik_3
bump: Traefik 3.5.0
2025-08-23 19:53:47 -06:00
Mauricio Siu
dd8f5dba09 Merge branch 'canary' into fix/traefik_3 2025-08-23 19:53:40 -06:00
Mauricio Siu
e72a468c7e Merge pull request #2111 from Marukome0743/traefik
feat: bump Traefik v3.2.2 and add swarm network label
2025-08-23 19:50:50 -06:00
Mauricio Siu
02dd793dfb Merge pull request #2396 from alexevladgabriel/feat/self-env-refs
feat: Self reference env variables
2025-08-23 19:38:34 -06:00
Mauricio Siu
64ef033950 Merge pull request #2418 from periakteon/canary
fix(organization): integrate active organization refetching on update/create
2025-08-23 19:32:45 -06:00
Mauricio Siu
32f7bdf398 Merge pull request #2450 from Dokploy/2403-no-delete-volumes-option-when-deleting-in-bulk
feat(ui): add bulk deploy functionality for services in project dashb…
2025-08-23 16:59:03 -06:00
Mauricio Siu
8d73b77a19 Merge branch 'canary' into 2403-no-delete-volumes-option-when-deleting-in-bulk 2025-08-23 16:08:15 -06:00
Mauricio Siu
2e3d4f1021 feat(ui): implement bulk delete dialog for services in project dashboard 2025-08-23 16:06:25 -06:00
Mauricio Siu
ba1f4dbd3a feat(ui): add bulk deploy functionality for services in project dashboard 2025-08-23 16:04:13 -06:00
Mauricio Siu
653beac3d9 feat(ui): implement bulk delete dialog for services with volume deletion option 2025-08-23 15:55:56 -06:00
Vlad Vladov
37c34fdadc feat(volume): Add possibility to keep latest N backups for custom containers 2025-08-23 18:07:45 +03:00
Paul Sommer
d52fe5c050 fix: typo in ntfy provider 2025-08-21 15:57:20 +02:00
Paul Sommer
36281cd5d3 feat(notification): add ntfy notifications 2025-08-20 20:23:44 +02:00
Masum Gökyüz
69d676178f feat(organization): integrate active organization refetching on update/create 2025-08-20 09:33:01 +03:00
Vyacheslav Scherbinin
6612c92b4f chore: update ai providers 2025-08-20 13:16:04 +07:00
Vyacheslav Scherbinin
88c8fe4614 chore: update ollama ai provider 2025-08-20 00:58:39 +07:00
Vyacheslav Scherbinin
623fc26de5 fix(ai-ui): hide api key field for ollama 2025-08-19 23:56:54 +07:00
Vyacheslav Scherbinin
220576fd63 fix(ai-ui): empty models list text 2025-08-19 23:56:54 +07:00
Vyacheslav Scherbinin
07c23292da fix(ai): ollama fetch models 2025-08-19 23:56:54 +07:00
Vyacheslav Scherbinin
72fca80047 fix(ai-ui): disable AI key autocomplete 2025-08-19 23:56:54 +07:00
Vyacheslav Scherbinin
1e7f614bb6 fix(ai): ollama provider url-based detection 2025-08-19 23:56:53 +07:00
Vyacheslav Scherbinin
e2662a0ec5 fix(ai): ollama ai provider api url 2025-08-19 23:56:46 +07:00
ispareh
c96c25ca9f feat: add autofocus to two-factor authentication input 2025-08-19 14:40:04 +03:30
Marukome0743
4afd2d11fa feat: bump traefik to v3.2.2 2025-08-19 18:57:03 +09:00
Scai
8cc054389a feat: add self reference for env variables 2025-08-18 02:04:23 +03:00
autofix-ci[bot]
2c591cbd03 [autofix.ci] apply automated fixes 2025-08-13 01:25:30 +00:00
Vyacheslav Scherbinin
3864c50deb bump: Traefik v3.5.0 2025-08-13 08:23:30 +07:00
PiquelChips
15e62961e8 fix: would only create previews if none of the labels were present 2025-08-11 14:09:02 +02:00
PiquelChips
429c1e4cd8 feat: better UI for submitting labels 2025-08-11 14:03:30 +02:00
Piquel
1904a3d1e9 Merge branch 'canary' into feat/label-previews 2025-08-11 13:29:04 +02:00
Lucas Manchine
8ea64f9de1 testing changes 2025-08-06 14:55:30 -03:00
Lucas Manchine
825a1fc495 Merge branch 'canary' into feature/stop-grace-period-2227 2025-08-06 10:30:57 -03:00
Mauricio Siu
7b76bb93b3 Merge branch 'canary' into feature/stop-grace-period-2227 2025-08-02 19:37:24 -06:00
Mauricio Siu
025d439f71 Merge branch 'canary' into feat/label-previews 2025-08-02 00:28:52 -06:00
Lucas Manchine
64290fcbf6 fix linter issues 2025-07-29 09:33:19 -03:00
autofix-ci[bot]
9baafb83ff [autofix.ci] apply automated fixes 2025-07-28 07:38:28 +00:00
PiquelChips
1f9ef473f1 format some files 2025-07-24 19:45:43 +02:00
PiquelChips
a0bbf7be23 add check for presence of labels 2025-07-24 19:35:33 +02:00
PiquelChips
a5bc384d77 run database migration 2025-07-24 19:02:50 +02:00
Lucas Manchine
4f2b270f1d improved form 2025-07-23 18:32:42 -03:00
Lucas Manchine
e22489926b feat: Add stop_grace_period to swarm settings, test layout 2025-07-23 21:18:57 +00:00
Lucas Manchine
b4a5221caf feat: Add stop_grace_period to swarm settings 2025-07-23 20:38:27 +00:00
PiquelChips
f2ae39aa86 feat: preview deployments for pull requests with specific labels 2025-07-23 21:39:54 +02:00
252 changed files with 81200 additions and 3498 deletions

View File

@@ -6,16 +6,13 @@ Please describe in a short paragraph what this PR is about.
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 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.
## Issues related (if applicable)
Close automatically the related issues using the keywords: `closes #ISSUE_NUMBER`, `fixes #ISSUE_NUMBER`, `resolves #ISSUE_NUMBER`
Example: `closes #123`
closes #123
## Screenshots (if applicable)
If you include a video or screenshot, would be awesome so we can see the changes in action.

BIN
.github/sponsors/tuple.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -2,7 +2,7 @@ name: Dokploy Docker Build
on:
push:
branches: [main, canary]
branches: [main, canary, "fix/re-apply-database-migration-fix"]
workflow_dispatch:
env:

View File

@@ -11,8 +11,25 @@
</div>
<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.

View File

@@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator";
import { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono";
import { logger } from "./logger.js";
import { type DeployJob, deployJobSchema } from "./schema.js";
import {
cancelDeploymentSchema,
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
},
],
retries: 0,
cancelOn: [
{
event: "deployment/cancelled",
if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId",
timeout: "1h", // Allow cancellation for up to 1 hour
},
],
},
{ event: "deployment/requested" },
@@ -119,6 +130,48 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
}
});
app.post(
"/cancel-deployment",
zValidator("json", cancelDeploymentSchema),
async (c) => {
const data = c.req.valid("json");
logger.info("Received cancel deployment request", data);
try {
// Send cancellation event to Inngest
await inngest.send({
name: "deployment/cancelled",
data,
});
const identifier =
data.applicationType === "application"
? `applicationId: ${data.applicationId}`
: `composeId: ${data.composeId}`;
logger.info("Deployment cancellation event sent", {
...data,
identifier,
});
return c.json({
message: "Deployment cancellation requested",
applicationType: data.applicationType,
});
} catch (error) {
logger.error("Failed to send deployment cancellation event", error);
return c.json(
{
message: "Failed to cancel deployment",
error: error instanceof Error ? error.message : String(error),
},
500,
);
}
},
);
app.get("/health", async (c) => {
return c.json({ status: "ok" });
});

View File

@@ -3,8 +3,8 @@ import { z } from "zod";
export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application"),
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
}),
z.object({
composeId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("compose"),
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
previewDeploymentId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
]);
export type DeployJob = z.infer<typeof deployJobSchema>;
export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
applicationType: z.literal("application"),
}),
z.object({
composeId: z.string(),
applicationType: z.literal("compose"),
}),
]);
export type CancelDeploymentJob = z.infer<typeof cancelDeploymentSchema>;

View File

@@ -18,14 +18,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") {
await rebuildRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
});
}
}
@@ -38,14 +38,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") {
await rebuildRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
});
}
}
@@ -57,8 +57,8 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
}

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = load(`
const expectedComposeFile1 = parse(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ configs:
`;
test("Add suffix to configs in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ configs:
`;
test("Add suffix to multiple configs in root property", () => {
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -92,7 +92,7 @@ configs:
`;
test("Add suffix to configs with different properties in root property", () => {
const composeData = load(
const composeData = parse(
composeFileDifferentProperties,
) as ComposeSpecification;
@@ -137,7 +137,7 @@ configs:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = load(`
const expectedComposeFileConfigRoot = parse(`
version: "3.8"
services:
@@ -162,7 +162,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs in root property", () => {
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -22,7 +22,7 @@ configs:
`;
test("Add suffix to configs in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -54,7 +54,7 @@ configs:
`;
test("Add suffix to configs in services with single config", () => {
const composeData = load(
const composeData = parse(
composeFileSingleServiceConfig,
) as ComposeSpecification;
@@ -108,7 +108,7 @@ configs:
`;
test("Add suffix to configs in services with multiple configs", () => {
const composeData = load(
const composeData = parse(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
@@ -157,7 +157,7 @@ services:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = load(`
const expectedComposeFileConfigServices = parse(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ services:
`) as ComposeSpecification;
test("Add suffix to configs in services", () => {
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -43,7 +43,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = load(`
const expectedComposeFileCombinedConfigs = parse(`
version: "3.8"
services:
@@ -77,7 +77,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to all configs in root and services", () => {
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
const suffix = "testhash";
@@ -122,7 +122,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = load(`
const expectedComposeFileWithEnvAndExternal = parse(`
version: "3.8"
services:
@@ -159,7 +159,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with environment and external", () => {
const composeData = load(
const composeData = parse(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
@@ -200,7 +200,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = load(`
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
version: "3.8"
services:
@@ -231,7 +231,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with template driver and labels", () => {
const composeData = load(
const composeData = parse(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to networks root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -79,7 +79,7 @@ networks:
`;
test("Add suffix to advanced networks root property (2 TRY)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -120,7 +120,7 @@ networks:
`;
test("Add suffix to networks with external properties", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -160,7 +160,7 @@ networks:
`;
test("Add suffix to networks with IPAM configurations", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -201,7 +201,7 @@ networks:
`;
test("Add suffix to networks with custom options", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const composeData = parse(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -264,7 +264,7 @@ networks:
`;
test("Add suffix to networks with static suffix", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const composeData = parse(composeFile6) as ComposeSpecification;
const suffix = "testhash";
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
}
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
const expectedComposeData = load(
const expectedComposeData = parse(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
@@ -293,7 +293,7 @@ networks:
`;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -23,7 +23,7 @@ services:
`;
test("Add suffix to networks in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to networks in services with aliases", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -107,7 +107,7 @@ networks:
`;
test("Add suffix to networks in services (Object with simple networks)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -153,7 +153,7 @@ networks:
`;
test("Add suffix to networks in services (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -196,7 +196,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -245,7 +245,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = load(composeFile8) as ComposeSpecification;
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -5,8 +5,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombined = `
version: "3.8"
@@ -39,7 +39,7 @@ networks:
`;
test("Add suffix to networks in services and root (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ networks:
`);
test("Add suffix to networks in compose file", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = "testhash";
if (!composeData?.networks) {
@@ -156,7 +156,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ networks:
`);
test("Add suffix to networks in compose file with external and internal networks", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -218,7 +218,7 @@ networks:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -247,7 +247,7 @@ networks:
`);
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -289,7 +289,7 @@ networks:
`;
const expectedComposeFile4 = load(`
const expectedComposeFile4 = parse(`
version: "3.8"
services:
@@ -326,7 +326,7 @@ networks:
`);
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ secrets:
`;
test("Add suffix to secrets in root property", () => {
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -52,7 +52,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 1)", () => {
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -84,7 +84,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 2)", () => {
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {

View File

@@ -3,8 +3,8 @@ import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileSecretsServices = `
version: "3.8"
@@ -21,7 +21,7 @@ secrets:
`;
test("Add suffix to secrets in services", () => {
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -54,7 +54,9 @@ secrets:
`;
test("Add suffix to secrets in services (Test 1)", () => {
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const composeData = parse(
composeFileSecretsServices1,
) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -93,7 +95,9 @@ secrets:
`;
test("Add suffix to secrets in services (Test 2)", () => {
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const composeData = parse(
composeFileSecretsServices2,
) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedSecrets = `
version: "3.8"
@@ -25,7 +25,7 @@ secrets:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = load(`
const expectedComposeFileCombinedSecrets = parse(`
version: "3.8"
services:
@@ -48,7 +48,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets", () => {
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -77,7 +77,7 @@ secrets:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = load(`
const expectedComposeFileCombinedSecrets3 = parse(`
version: "3.8"
services:
@@ -99,7 +99,9 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (3rd Case)", () => {
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const composeData = parse(
composeFileCombinedSecrets3,
) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -128,7 +130,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = load(`
const expectedComposeFileCombinedSecrets4 = parse(`
version: "3.8"
services:
@@ -150,7 +152,9 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (4th Case)", () => {
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const composeData = parse(
composeFileCombinedSecrets4,
) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to service names with container_name in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -32,7 +32,7 @@ networks:
`;
test("Add suffix to service names with depends_on (array) in compose file", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -102,7 +102,7 @@ networks:
`;
test("Add suffix to service names with depends_on (object) in compose file", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const composeData = parse(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -30,7 +30,7 @@ networks:
`;
test("Add suffix to service names with extends (string) in compose file", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const composeData = parse(composeFile6) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -90,7 +90,7 @@ networks:
`;
test("Add suffix to service names with extends (object) in compose file", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -31,7 +31,7 @@ networks:
`;
test("Add suffix to service names with links in compose file", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -26,7 +26,7 @@ networks:
`;
test("Add suffix to service names in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedAllCases = `
version: "3.8"
@@ -38,7 +38,7 @@ networks:
driver: bridge
`;
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -71,7 +71,9 @@ networks:
`);
test("Add suffix to all service names in compose file", () => {
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const composeData = parse(
composeFileCombinedAllCases,
) as ComposeSpecification;
const suffix = "testhash";
@@ -131,7 +133,7 @@ networks:
driver: bridge
`;
const expectedComposeFile1 = load(`
const expectedComposeFile1 = parse(`
version: "3.8"
services:
@@ -176,7 +178,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -227,7 +229,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -271,7 +273,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -322,7 +324,7 @@ networks:
driver: bridge
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -366,7 +368,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -35,7 +35,7 @@ networks:
`;
test("Add suffix to service names with volumes_from in compose file", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
services:
@@ -70,7 +70,7 @@ volumes:
driver: local
`;
const expectedDockerCompose = load(`
const expectedDockerCompose = parse(`
services:
mail:
image: bytemark/smtp
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
// Docker compose needs unique names for services, volumes, networks and containers
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
test("Add suffix to volumes root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
});
test("Expect to change the suffix in all the possible places", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose2 = load(`
const expectedDockerCompose2 = parse(`
version: '3.8'
services:
app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose3 = load(`
const expectedDockerCompose3 = parse(`
version: '3.8'
services:
app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config:
`;
const expectedDockerComposeComplex = load(`
const expectedDockerComposeComplex = parse(`
version: "3.8"
services:
studio:
@@ -1012,7 +1012,7 @@ volumes:
`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification;
const composeData = parse(composeFileComplex) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data:
`;
const expectedDockerComposeExample1 = load(`
const expectedDockerComposeExample1 = parse(`
version: "3.8"
services:
web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => {
const composeData = load(composeFileExample1) as ComposeSpecification;
const composeData = parse(composeFileExample1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache:
`;
const expectedDockerComposeBackrest = load(`
const expectedDockerComposeBackrest = parse(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification;
const composeData = parse(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to volumes in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 2)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -101,7 +101,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 3)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -148,7 +148,7 @@ volumes:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = load(`
const expectedComposeFile4 = parse(`
version: "3.8"
services:
@@ -179,7 +179,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes in root property", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -24,7 +24,7 @@ services:
`;
test("Add suffix to volumes declared directly in services", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ volumes:
`;
test("Add suffix to volumes declared directly in services (Case 2)", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileTypeVolume = `
version: "3.8"
@@ -23,7 +23,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume = load(`
const expectedComposeFileTypeVolume = parse(`
version: "3.8"
services:
@@ -44,7 +44,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes with type: volume in services", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const suffix = "testhash";
@@ -73,7 +73,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume1 = load(`
const expectedComposeFileTypeVolume1 = parse(`
version: "3.8"
services:
@@ -93,7 +93,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to mixed volumes in services", () => {
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
const suffix = "testhash";
@@ -128,7 +128,7 @@ volumes:
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = load(`
const expectedComposeFileTypeVolume2 = parse(`
version: "3.8"
services:
@@ -154,7 +154,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex volume configurations in services", () => {
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
const suffix = "testhash";
@@ -218,7 +218,7 @@ volumes:
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = load(`
const expectedComposeFileTypeVolume3 = parse(`
version: "3.8"
services:
@@ -273,7 +273,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex nested volumes configuration in services", () => {
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -27,6 +27,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
@@ -55,13 +56,21 @@ const baseApp: ApplicationNested = {
previewPort: 3000,
previewLimit: 0,
previewWildcard: "",
project: {
environment: {
env: "",
organizationId: "",
environmentId: "",
name: "",
description: "",
createdAt: "",
description: "",
projectId: "",
project: {
env: "",
organizationId: "",
name: "",
description: "",
createdAt: "",
projectId: "",
},
},
buildArgs: null,
buildPath: "/",
@@ -91,6 +100,7 @@ const baseApp: ApplicationNested = {
dockerfile: null,
dockerImage: null,
dropBuildPath: null,
environmentId: "",
enabled: null,
env: null,
healthCheckSwarm: null,
@@ -105,7 +115,6 @@ const baseApp: ApplicationNested = {
password: null,
placementSwarm: null,
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
@@ -124,6 +133,7 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
};
describe("unzipDrop using real zip files", () => {

View File

@@ -0,0 +1,335 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
PORT=3000
`;
const environmentEnv = `
NODE_ENV=development
API_URL=https://api.dev.example.com
REDIS_URL=redis://localhost:6379
DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("prepareEnvironmentVariables (environment variables)", () => {
it("resolves environment variables correctly", () => {
const serviceWithEnvVars = `
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
SERVICE_PORT=4000
`;
const resolved = prepareEnvironmentVariables(
serviceWithEnvVars,
"",
environmentEnv,
);
expect(resolved).toEqual([
"NODE_ENV=development",
"API_URL=https://api.dev.example.com",
"SERVICE_PORT=4000",
]);
});
it("resolves both project and environment variables", () => {
const serviceWithBoth = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const resolved = prepareEnvironmentVariables(
serviceWithBoth,
projectEnv,
environmentEnv,
);
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"NODE_ENV=development",
"API_URL=https://api.dev.example.com",
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"SERVICE_PORT=4000",
]);
});
it("handles undefined environment variables", () => {
const serviceWithUndefined = `
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
prepareEnvironmentVariables(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
it("allows service variables to override environment variables", () => {
const serviceOverrideEnv = `
NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const resolved = prepareEnvironmentVariables(
serviceOverrideEnv,
"",
environmentEnv,
);
expect(resolved).toEqual([
"NODE_ENV=production", // Overrides environment variable
"API_URL=https://api.dev.example.com",
]);
});
it("resolves complex references with project, environment, and service variables", () => {
const complexServiceEnv = `
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(
complexServiceEnv,
projectEnv,
environmentEnv,
);
expect(resolved).toEqual([
"FULL_DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db/dev_database",
"API_ENDPOINT=https://api.dev.example.com/staging/api",
"SERVICE_NAME=my-service",
"COMPLEX_VAR=my-service-development-staging",
]);
});
it("handles environment variables with special characters", () => {
const specialEnvVars = `
SPECIAL_URL=https://special.com
COMPLEX_KEY="key-with-@#$%^&*()"
JWT_SECRET="secret-with-spaces and symbols!@#"
`;
const serviceWithSpecial = `
FULL_URL=\${{environment.SPECIAL_URL}}/path?key=\${{environment.COMPLEX_KEY}}
AUTH_SECRET=\${{environment.JWT_SECRET}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithSpecial,
"",
specialEnvVars,
);
expect(resolved).toEqual([
"FULL_URL=https://special.com/path?key=key-with-@#$%^&*()",
"AUTH_SECRET=secret-with-spaces and symbols!@#",
]);
});
it("maintains precedence: service > environment > project", () => {
const conflictingProjectEnv = `
NODE_ENV=production-project
API_URL=https://project.api.com
DATABASE_NAME=project_db
`;
const conflictingEnvironmentEnv = `
NODE_ENV=development-environment
API_URL=https://environment.api.com
DATABASE_NAME=env_db
`;
const serviceWithConflicts = `
NODE_ENV=service-override
PROJECT_ENV=\${{project.NODE_ENV}}
ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(resolved).toEqual([
"NODE_ENV=service-override", // Service wins
"PROJECT_ENV=production-project", // Project reference
"ENV_VAR=https://environment.api.com", // Environment reference
"DB_NAME=env_db", // Environment reference
]);
});
it("handles empty environment variables", () => {
const serviceWithEmpty = `
SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithEmpty,
projectEnv,
"",
);
expect(resolved).toEqual(["SERVICE_VAR=test", "PROJECT_VAR=staging"]);
});
it("handles mixed quotes and environment variables", () => {
const envWithQuotes = `
QUOTED_VAR="development"
SINGLE_QUOTED='https://api.dev.example.com'
MIXED_VAR="value with 'single' quotes"
`;
const serviceWithQuotes = `
NODE_ENV=\${{environment.QUOTED_VAR}}
API_URL=\${{environment.SINGLE_QUOTED}}
COMPLEX="Prefix-\${{environment.MIXED_VAR}}-Suffix"
`;
const resolved = prepareEnvironmentVariables(
serviceWithQuotes,
"",
envWithQuotes,
);
expect(resolved).toEqual([
"NODE_ENV=development",
"API_URL=https://api.dev.example.com",
"COMPLEX=Prefix-value with 'single' quotes-Suffix",
]);
});
it("resolves multiple environment references in single value", () => {
const multiRefEnv = `
HOST=localhost
PORT=5432
USERNAME=postgres
PASSWORD=secret123
`;
const serviceWithMultiRefs = `
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
CONNECTION_STRING=\${{environment.HOST}}:\${{environment.PORT}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithMultiRefs,
"",
multiRefEnv,
);
expect(resolved).toEqual([
"DATABASE_URL=postgresql://postgres:secret123@localhost:5432/mydb",
"CONNECTION_STRING=localhost:5432",
]);
});
it("handles nested references with environment and project variables", () => {
const nestedProjectEnv = `
BASE_DOMAIN=example.com
PROTOCOL=https
`;
const nestedEnvironmentEnv = `
SUBDOMAIN=api.dev
PATH_PREFIX=/v1
`;
const serviceWithNested = `
FULL_URL=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}\${{environment.PATH_PREFIX}}/endpoint
API_BASE=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithNested,
nestedProjectEnv,
nestedEnvironmentEnv,
);
expect(resolved).toEqual([
"FULL_URL=https://api.dev.example.com/v1/endpoint",
"API_BASE=https://api.dev.example.com",
]);
});
it("throws error for malformed environment variable references", () => {
const serviceWithMalformed = `
MALFORMED1=\${{environment.}}
MALFORMED2=\${{environment}}
VALID=\${{environment.NODE_ENV}}
`;
// Should throw error for empty variable name after environment.
expect(() =>
prepareEnvironmentVariables(serviceWithMalformed, "", environmentEnv),
).toThrow("Invalid environment variable: environment.");
});
it("handles environment variables with numeric values", () => {
const numericEnv = `
PORT=8080
TIMEOUT=30
RETRY_COUNT=3
PERCENTAGE=99.5
`;
const serviceWithNumeric = `
SERVER_PORT=\${{environment.PORT}}
REQUEST_TIMEOUT=\${{environment.TIMEOUT}}
MAX_RETRIES=\${{environment.RETRY_COUNT}}
SUCCESS_RATE=\${{environment.PERCENTAGE}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithNumeric,
"",
numericEnv,
);
expect(resolved).toEqual([
"SERVER_PORT=8080",
"REQUEST_TIMEOUT=30",
"MAX_RETRIES=3",
"SUCCESS_RATE=99.5",
]);
});
it("handles boolean-like environment variables", () => {
const booleanEnv = `
DEBUG=true
ENABLED=false
PRODUCTION=1
DEVELOPMENT=0
`;
const serviceWithBoolean = `
DEBUG_MODE=\${{environment.DEBUG}}
FEATURE_ENABLED=\${{environment.ENABLED}}
IS_PROD=\${{environment.PRODUCTION}}
IS_DEV=\${{environment.DEVELOPMENT}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithBoolean,
"",
booleanEnv,
);
expect(resolved).toEqual([
"DEBUG_MODE=true",
"FEATURE_ENABLED=false",
"IS_PROD=1",
"IS_DEV=0",
]);
});
});

View File

@@ -177,3 +177,77 @@ COMPLEX_VAR="'Prefix \"DoubleQuoted\" and \${{project.APP_NAME}}'"
]);
});
});
describe("prepareEnvironmentVariables (self references)", () => {
it("resolves self references correctly", () => {
const serviceEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
SELF_REF=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"SELF_REF=staging",
]);
});
it("throws on undefined self references", () => {
const serviceEnv = `
MISSING_VAR=\${{UNDEFINED_VAR}}
`;
expect(() => prepareEnvironmentVariables(serviceEnv, "")).toThrow(
"Invalid service environment variable: UNDEFINED_VAR",
);
});
it("allows overriding and still resolving from self", () => {
const serviceEnv = `
ENVIRONMENT=production
OVERRIDE_ENV=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"OVERRIDE_ENV=production",
]);
});
it("resolves multiple self references inside one value", () => {
const serviceEnv = `
ENVIRONMENT=staging
APP_NAME=MyApp
COMPLEX=\${{APP_NAME}}-\${{ENVIRONMENT}}-\${{APP_NAME}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"APP_NAME=MyApp",
"COMPLEX=MyApp-staging-MyApp",
]);
});
it("handles quotes with self references", () => {
const serviceEnv = `
ENVIRONMENT=production
QUOTED="'\${{ENVIRONMENT}}'"
MIXED="\"Double \${{ENVIRONMENT}}\""
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"QUOTED='production'",
'MIXED="Double production"',
]);
});
});

View File

@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
type MockCreateServiceOptions = {
StopGracePeriod?: number;
[key: string]: unknown;
};
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<[], Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
async () => undefined,
);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
}));
return {
inspectMock: inspect,
getServiceMock: getService,
createServiceMock: createService,
getRemoteDockerMock: getRemoteDocker,
};
});
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: getRemoteDockerMock,
}));
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "dockerfile",
env: null,
mounts: [],
cpuLimit: null,
memoryLimit: null,
memoryReservation: null,
cpuReservation: null,
command: null,
ports: [],
sourceType: "docker",
dockerImage: "example:latest",
registry: null,
environment: {
project: { env: null },
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0n,
serverId: "server-id",
...overrides,
}) as unknown as ApplicationNested;
describe("mechanizeDockerContainer", () => {
beforeEach(() => {
inspectMock.mockReset();
inspectMock.mockRejectedValue(new Error("service not found"));
getServiceMock.mockClear();
createServiceMock.mockClear();
getRemoteDockerMock.mockClear();
getRemoteDockerMock.mockResolvedValue({
getService: getServiceMock,
createService: createServiceMock,
});
});
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.StopGracePeriod).toBe(0);
expect(typeof settings.StopGracePeriod).toBe("number");
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
const application = createApplication({ stopGracePeriodSwarm: null });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings).not.toHaveProperty("StopGracePeriod");
});
});

View File

@@ -6,6 +6,7 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
rollbackActive: false,
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
@@ -35,13 +36,22 @@ const baseApp: ApplicationNested = {
previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "",
project: {
environmentId: "",
environment: {
env: "",
organizationId: "",
environmentId: "",
name: "",
description: "",
createdAt: "",
description: "",
projectId: "",
project: {
env: "",
organizationId: "",
name: "",
description: "",
createdAt: "",
projectId: "",
},
},
buildPath: "/",
gitlabPathNamespace: "",
@@ -84,7 +94,6 @@ const baseApp: ApplicationNested = {
password: null,
placementSwarm: null,
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
@@ -102,6 +111,7 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
};
const baseDomain: Domain = {

View File

@@ -25,6 +25,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -176,10 +177,18 @@ const addSwarmSettings = z.object({
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
@@ -224,12 +233,22 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: null,
labelsSwarm: null,
networkSwarm: null,
stopGracePeriodSwarm: null,
},
resolver: zodResolver(addSwarmSettings),
});
useEffect(() => {
if (data) {
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
? data.stopGracePeriodSwarm
: null;
const normalizedStopGracePeriod =
stopGracePeriodValue === null || stopGracePeriodValue === undefined
? null
: typeof stopGracePeriodValue === "bigint"
? stopGracePeriodValue
: BigInt(stopGracePeriodValue);
form.reset({
healthCheckSwarm: data.healthCheckSwarm
? JSON.stringify(data.healthCheckSwarm, null, 2)
@@ -255,6 +274,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
networkSwarm: data.networkSwarm
? JSON.stringify(data.networkSwarm, null, 2)
: null,
stopGracePeriodSwarm: normalizedStopGracePeriod,
});
}
}, [form, form.reset, data]);
@@ -275,6 +295,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: data.modeSwarm,
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
})
.then(async () => {
toast.success("Swarm settings updated");
@@ -352,9 +373,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
"Interval" : 10000,
"Timeout" : 10000,
"StartPeriod" : 10000,
"Interval" : 10000000000,
"Timeout" : 10000000000,
"StartPeriod" : 10000000000,
"Retries" : 10
}`}
className="h-[12rem] font-mono"
@@ -407,9 +428,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Condition" : "on-failure",
"Delay" : 10000,
"Delay" : 10000000000,
"MaxAttempts" : 10,
"Window" : 10000
"Window" : 10000000000
} `}
className="h-[12rem] font-mono"
{...field}
@@ -529,9 +550,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"Delay" : 10000000000,
"FailureAction" : "continue",
"Monitor" : 10000,
"Monitor" : 10000000000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
@@ -587,9 +608,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"Delay" : 10000000000,
"FailureAction" : "continue",
"Monitor" : 10000,
"Monitor" : 10000000000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
@@ -774,7 +795,57 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="stopGracePeriodSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Duration in nanoseconds
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`Enter duration in nanoseconds:
• 30000000000 - 30 seconds
• 120000000000 - 2 minutes
• 3600000000000 - 1 hour
• 0 - no grace period`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<Input
type="number"
placeholder="30000000000"
className="font-mono"
{...field}
value={field?.value?.toString() || ""}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
<Button
isLoading={isLoading}

View File

@@ -1,8 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import jsyaml from "js-yaml";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { parse, stringify, YAMLParseError } from "yaml";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
@@ -38,11 +38,11 @@ interface Props {
export const validateAndFormatYAML = (yamlText: string) => {
try {
const obj = jsyaml.load(yamlText);
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
const obj = parse(yamlText);
const formattedYaml = stringify(obj, { indent: 4 });
return { valid: true, formattedYaml, error: null };
} catch (error) {
if (error instanceof jsyaml.YAMLException) {
if (error instanceof YAMLParseError) {
return {
valid: false,
formattedYaml: yamlText,
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
message: (error as string) || "Invalid YAML",
});
return;
}

View File

@@ -1,6 +1,7 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -61,12 +62,48 @@ export const ShowDeployments = ({
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
// Cancel deployment mutations
const {
mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp,
} = api.application.cancelDeployment.useMutation();
const {
mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
if (!isCloud || !deployments || deployments.length === 0) return null;
const now = Date.now();
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
// Get the most recent deployment (first in the list since they're sorted by date)
const mostRecentDeployment = deployments[0];
if (
!mostRecentDeployment ||
mostRecentDeployment.status !== "running" ||
!mostRecentDeployment.startedAt
) {
return null;
}
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
const elapsed = now - startTime;
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
}, [isCloud, deployments]);
useEffect(() => {
setUrl(document.location.origin);
}, []);
@@ -77,7 +114,7 @@ export const ShowDeployments = ({
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this {type}
See the last 10 deployments for this {type}
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{stuckDeployment && (type === "application" || type === "compose") && (
<AlertBlock
type="warning"
className="flex-col items-start w-full p-4"
>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium text-sm mb-1">
Build appears to be stuck
</div>
<p className="text-sm">
Hey! Looks like the build has been running for more than 10
minutes. Would you like to cancel this deployment?
</p>
</div>
<Button
variant="destructive"
size="sm"
className="w-fit"
isLoading={
type === "application" ? isCancellingApp : isCancellingCompose
}
onClick={async () => {
try {
if (type === "application") {
await cancelApplicationDeployment({
applicationId: id,
});
} else if (type === "compose") {
await cancelComposeDeployment({
composeId: id,
});
}
toast.success("Deployment cancellation requested");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to cancel deployment",
);
}
}}
>
Cancel Deployment
</Button>
</div>
</AlertBlock>
)}
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>
@@ -104,7 +189,9 @@ export const ShowDeployments = ({
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
{`${url}/api/deploy${
type === "compose" ? "/compose" : ""
}/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />

View File

@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const router = useRouter();
const { mutateAsync, isLoading } =
api.application.saveGitProdiver.useMutation();
api.application.saveGitProvider.useMutation();
const form = useForm<GitProvider>({
defaultValues: {

View File

@@ -69,7 +69,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {

View File

@@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings2 } from "lucide-react";
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -33,6 +34,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const schema = z
@@ -42,6 +49,7 @@ const schema = z
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
previewLabels: z.array(z.string()).optional(),
previewHttps: z.boolean(),
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
@@ -81,6 +89,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewLabels: [],
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
@@ -102,6 +111,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
buildArgs: data.previewBuildArgs || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
@@ -119,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewBuildArgs: formData.buildArgs,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
previewLabels: formData.previewLabels,
applicationId,
previewLimit: formData.previewLimit,
previewHttps: formData.previewHttps,
@@ -200,6 +211,90 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLabels"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Preview Labels</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add a labels that will trigger a preview
deployment for a pull request. If no labels
are specified, all pull requests will trigger
a preview deployment.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((label, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{label}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newLabels = [...(field.value || [])];
newLabels.splice(index, 1);
field.onChange(newLabels);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a label (e.g. enhancements, needs-review)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const label = input.value.trim();
if (label) {
field.onChange([
...(field.value || []),
label,
]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a label"]',
) as HTMLInputElement;
const label = input.value.trim();
if (label) {
field.onChange([...(field.value || []), label]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLimit"

View File

@@ -7,7 +7,7 @@ import {
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { type Control, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -57,6 +57,7 @@ export const commonCronExpressions = [
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
{ label: "Every 15 minutes", value: "*/15 * * * *" },
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
{ label: "Custom", value: "custom" },
];
const formSchema = z
@@ -115,10 +116,91 @@ interface Props {
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const ScheduleFormField = ({
name,
formControl,
}: {
name: string;
formControl: Control<any>;
}) => {
const [selectedOption, setSelectedOption] = useState("");
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>Cron expression format: minute hour day month weekday</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
value={selectedOption}
onValueChange={(value) => {
setSelectedOption(value);
field.onChange(value === "custom" ? "" : value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label}
{expr.value !== "custom" && ` (${expr.value})`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
onChange={(e) => {
const value = e.target.value;
const commonExpression = commonCronExpressions.find(
(expression) => expression.value === value,
);
if (commonExpression) {
setSelectedOption(commonExpression.value);
} else {
setSelectedOption("custom");
}
field.onChange(e);
}}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -377,63 +459,9 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<FormField
control={form.control}
<ScheduleFormField
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
formControl={form.control}
/>
{(scheduleTypeForm === "application" ||

View File

@@ -58,7 +58,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
@@ -91,15 +91,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return (
<div
key={schedule.scheduleId}
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<Clock className="size-4 text-primary/70" />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium leading-none">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
{schedule.name}
</h3>
<Badge
@@ -109,7 +109,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
@@ -142,7 +142,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5 md:gap-1.5">
<ShowDeploymentsModal
id={schedule.scheduleId}
type="schedule"
@@ -226,7 +226,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
})}
</div>
) : (
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<Clock className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground">
No scheduled tasks

View File

@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
DatabaseZap,
Info,
PenBoxIcon,
PlusCircle,
RefreshCw,
} from "lucide-react";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,7 +41,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { commonCronExpressions } from "../schedules/handle-schedules";
import { ScheduleFormField } from "../schedules/handle-schedules";
const formSchema = z
.object({
@@ -55,7 +49,12 @@ const formSchema = z
cronExpression: z.string().min(1, "Cron expression is required"),
volumeName: z.string().min(1, "Volume name is required"),
prefix: z.string(),
// keepLatestCount: z.coerce.number().optional(),
keepLatestCount: z.coerce
.number()
.int()
.gte(1, "Must be at least 1")
.optional()
.nullable(),
turnOff: z.boolean().default(false),
enabled: z.boolean().default(true),
serviceType: z.enum([
@@ -108,6 +107,7 @@ export const HandleVolumeBackups = ({
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
@@ -117,7 +117,7 @@ export const HandleVolumeBackups = ({
cronExpression: "",
volumeName: "",
prefix: "",
// keepLatestCount: undefined,
keepLatestCount: undefined,
turnOff: false,
enabled: true,
serviceName: "",
@@ -173,13 +173,19 @@ export const HandleVolumeBackups = ({
cronExpression: volumeBackup.cronExpression,
volumeName: volumeBackup.volumeName || "",
prefix: volumeBackup.prefix,
// keepLatestCount: volumeBackup.keepLatestCount || undefined,
keepLatestCount: volumeBackup.keepLatestCount || undefined,
turnOff: volumeBackup.turnOff,
enabled: volumeBackup.enabled || false,
serviceName: volumeBackup.serviceName || "",
destinationId: volumeBackup.destinationId,
serviceType: volumeBackup.serviceType,
});
setKeepLatestCountInput(
volumeBackup.keepLatestCount !== null &&
volumeBackup.keepLatestCount !== undefined
? String(volumeBackup.keepLatestCount)
: "",
);
}
}, [form, volumeBackup, volumeBackupId]);
@@ -190,8 +196,12 @@ export const HandleVolumeBackups = ({
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!id && !volumeBackupId) return;
const preparedKeepLatestCount =
keepLatestCountInput === "" ? null : (values.keepLatestCount ?? null);
await mutateAsync({
...values,
keepLatestCount: preparedKeepLatestCount,
destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType,
@@ -257,9 +267,8 @@ export const HandleVolumeBackups = ({
</DialogTrigger>
<DialogContent
className={cn(
"overflow-y-auto",
volumeBackupType === "compose" || volumeBackupType === "application"
? "max-h-[95vh] sm:max-w-2xl"
? "sm:max-w-2xl"
: " sm:max-w-lg",
)}
>
@@ -291,64 +300,9 @@ export const HandleVolumeBackups = ({
</FormItem>
)}
/>
<FormField
control={form.control}
<ScheduleFormField
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
formControl={form.control}
/>
<FormField
@@ -600,29 +554,38 @@ export const HandleVolumeBackups = ({
)}
/>
{/* <FormField
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => (
<FormItem>
<FormLabel>Keep Latest Count</FormLabel>
<FormLabel>Keep Latest Backups</FormLabel>
<FormControl>
<Input
type="number"
placeholder="5"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value) || undefined)
}
type="number"
min={1}
autoComplete="off"
placeholder="Leave empty to keep all"
value={keepLatestCountInput}
onChange={(e) => {
const raw = e.target.value;
setKeepLatestCountInput(raw);
if (raw === "") {
field.onChange(undefined);
} else if (/^\d+$/.test(raw)) {
field.onChange(Number(raw));
}
}}
/>
</FormControl>
<FormDescription>
Number of backup files to keep (optional)
How many recent backups to keep. Empty means no cleanup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
/>
<FormField
control={form.control}

View File

@@ -101,7 +101,9 @@ export const DeleteService = ({ id, type }: Props) => {
deleteVolumes,
})
.then((result) => {
push(`/dashboard/project/${result?.projectId}`);
push(
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
);
toast.success("deleted successfully");
setIsOpen(false);
})

View File

@@ -47,7 +47,7 @@ export const ComposeActions = ({ composeId }: Props) => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
@@ -195,6 +195,7 @@ export const ComposeActions = ({ composeId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
>
<Button
variant="outline"

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -35,6 +35,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm<AddComposeFile>({
defaultValues: {
@@ -53,6 +54,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
}
}, [form, form.reset, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {
setHasUnsavedChanges(composeFile !== data.composeFile);
}
}, [composeFile, data?.composeFile]);
const onSubmit = async (data: AddComposeFile) => {
const { valid, error } = validateAndFormatYAML(data.composeFile);
if (!valid) {
@@ -71,6 +78,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
})
.then(async () => {
toast.success("Compose config Updated");
setHasUnsavedChanges(false);
refetch();
await utils.compose.getConvertedCompose.invalidate({
composeId,
@@ -99,6 +107,19 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return (
<>
<div className="w-full flex flex-col gap-4 ">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Compose File</h3>
<p className="text-sm text-muted-foreground">
Configure your Docker Compose file for this service.
{hasUnsavedChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</p>
</div>
</div>
<Form {...form}>
<form
id="hook-form-save-compose-file"

View File

@@ -37,8 +37,6 @@ interface Props {
serverId?: string;
}
badgeStateColor;
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();

View File

@@ -3,7 +3,6 @@ import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
PlusIcon,
RefreshCw,
@@ -62,7 +61,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
type CacheType = "cache" | "fetch";
@@ -579,66 +578,9 @@ export const HandleBackup = ({
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<ScheduleFormField name="schedule" formControl={form.control} />
<FormField
control={form.control}
name="prefix"

View File

@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface Props {
id: string;
containerId: string;
containerId?: string;
serverId?: string;
}
@@ -36,7 +36,6 @@ export const DockerTerminal: React.FC<Props> = ({
},
});
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
@@ -57,7 +56,7 @@ export const DockerTerminal: React.FC<Props> = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>

View File

@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

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

View File

@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -1,10 +1,10 @@
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddAiAssistant = ({ projectId }: Props) => {
return <TemplateGenerator projectId={projectId} />;
export const AddAiAssistant = ({ environmentId }: Props) => {
return <TemplateGenerator environmentId={environmentId} />;
};

View File

@@ -64,11 +64,11 @@ const AddTemplateSchema = z.object({
type AddTemplate = z.infer<typeof AddTemplateSchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddApplication = ({ projectId, projectName }: Props) => {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const [visible, setVisible] = useState(false);
@@ -76,6 +76,10 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation();
@@ -94,15 +98,15 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
description: data.description,
projectId,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
})
.then(async () => {
toast.success("Service Created");
form.reset();
setVisible(false);
await utils.project.one.invalidate({
projectId,
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -157,7 +161,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
</FormItem>
)}
/>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -186,13 +190,27 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={field.value}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -206,7 +224,9 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -65,11 +65,11 @@ const AddComposeSchema = z.object({
type AddCompose = z.infer<typeof AddComposeSchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddCompose = ({ projectId, projectName }: Props) => {
export const AddCompose = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
@@ -78,7 +78,14 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCompose>({
defaultValues: {
@@ -98,16 +105,17 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
await mutateAsync({
name: data.name,
description: data.description,
projectId,
environmentId,
composeType: data.composeType,
appName: data.appName,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
})
.then(async () => {
toast.success("Compose Created");
setVisible(false);
await utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -165,7 +173,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
)}
/>
</div>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -194,13 +202,27 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={field.value}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -214,7 +236,9 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -83,7 +83,12 @@ const baseDatabaseSchema = z.object({
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
databasePassword: z.string(),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
dockerImage: z.string(),
description: z.string().nullable(),
serverId: z.string().nullable(),
@@ -112,7 +117,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z.string().default(""),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
@@ -121,7 +132,13 @@ const mySchema = z.discriminatedUnion("type", [
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z.string().default(""),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
@@ -154,14 +171,15 @@ const databasesMap = {
type AddDatabase = z.infer<typeof mySchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddDatabase = ({ projectId, projectName }: Props) => {
export const AddDatabase = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
@@ -169,7 +187,14 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
const mariadbMutation = api.mariadb.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddDatabase>({
defaultValues: {
@@ -203,8 +228,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
description: data.description,
};
@@ -216,7 +241,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mongo") {
promise = mongoMutation.mutateAsync({
@@ -224,25 +249,24 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets,
});
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId,
projectId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword,
databaseRootPassword: data.databaseRootPassword || "",
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({
@@ -251,8 +275,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseName: data.databaseName || "mysql",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
databaseRootPassword: data.databaseRootPassword,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "",
});
}
@@ -271,8 +295,9 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseUser: "",
});
setVisible(false);
await utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -382,7 +407,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
</FormItem>
)}
/>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -391,13 +416,29 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<FormLabel>Select a Server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -407,7 +448,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length})
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>

View File

@@ -73,11 +73,11 @@ import { api } from "@/utils/api";
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
interface Props {
projectId: string;
environmentId: string;
baseUrl?: string;
}
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
@@ -91,6 +91,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
return undefined;
});
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
// Save to localStorage when customBaseUrl changes
useEffect(() => {
if (customBaseUrl) {
@@ -138,6 +141,10 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
}) || [];
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -164,7 +171,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
className="w-full sm:w-[200px]"
className="w-full"
value={query}
/>
<Input
@@ -241,7 +248,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
}
className="h-9 w-9"
className="h-9 w-9 flex-shrink-0"
>
{viewMode === "detailed" ? (
<LayoutGrid className="size-4" />
@@ -427,7 +434,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
project.
</AlertDialogDescription>
{hasServers && (
{shouldShowServerDropdown && (
<div>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -456,12 +463,29 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
onValueChange={(e) => {
setServerId(e);
}}
defaultValue={
!isCloud ? "dokploy" : undefined
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -476,7 +500,8 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length})
Servers (
{servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -490,16 +515,20 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
disabled={isLoading}
onClick={async () => {
const promise = mutateAsync({
projectId,
serverId: serverId || undefined,
serverId:
serverId === "dokploy"
? undefined
: serverId,
environmentId,
id: template.id,
baseUrl: customBaseUrl,
});
toast.promise(promise, {
loading: "Setting up...",
success: () => {
utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
utils.environment.one.invalidate({
environmentId,
});
setOpen(false);
return `${template.name} template created successfully`;

View File

@@ -0,0 +1,457 @@
import type { findEnvironmentsByProjectId } from "@dokploy/server";
import {
ChevronDownIcon,
PencilIcon,
PlusIcon,
Terminal,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
type Environment = Awaited<
ReturnType<typeof findEnvironmentsByProjectId>
>[number];
interface AdvancedEnvironmentSelectorProps {
projectId: string;
currentEnvironmentId?: string;
}
export const AdvancedEnvironmentSelector = ({
projectId,
currentEnvironmentId,
}: AdvancedEnvironmentSelectorProps) => {
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] =
useState<Environment | null>(null);
const { data: environments } = api.environment.byProjectId.useQuery(
{ projectId: projectId },
{
enabled: !!projectId,
},
);
// Form states
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// Get current user's permissions
const { data: currentUser } = api.user.get.useQuery();
// Check if user can create environments
const canCreateEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true;
// Check if user can delete environments
const canDeleteEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const haveServices =
selectedEnvironment &&
((selectedEnvironment?.mariadb?.length || 0) > 0 ||
(selectedEnvironment?.mongo?.length || 0) > 0 ||
(selectedEnvironment?.mysql?.length || 0) > 0 ||
(selectedEnvironment?.postgres?.length || 0) > 0 ||
(selectedEnvironment?.redis?.length || 0) > 0 ||
(selectedEnvironment?.applications?.length || 0) > 0 ||
(selectedEnvironment?.compose?.length || 0) > 0);
const createEnvironment = api.environment.create.useMutation();
const updateEnvironment = api.environment.update.useMutation();
const deleteEnvironment = api.environment.remove.useMutation();
const duplicateEnvironment = api.environment.duplicate.useMutation();
// Refetch project data
const utils = api.useUtils();
const handleCreateEnvironment = async () => {
try {
await createEnvironment.mutateAsync({
projectId,
name: name.trim(),
description: description.trim() || null,
});
toast.success("Environment created successfully");
utils.environment.byProjectId.invalidate({ projectId });
setIsCreateDialogOpen(false);
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
}
};
const handleUpdateEnvironment = async () => {
if (!selectedEnvironment) return;
try {
await updateEnvironment.mutateAsync({
environmentId: selectedEnvironment.environmentId,
name: name.trim(),
description: description.trim() || null,
});
toast.success("Environment updated successfully");
utils.environment.byProjectId.invalidate({ projectId });
setIsEditDialogOpen(false);
setSelectedEnvironment(null);
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
}
};
const handleDeleteEnvironment = async () => {
if (!selectedEnvironment) return;
try {
await deleteEnvironment.mutateAsync({
environmentId: selectedEnvironment.environmentId,
});
toast.success("Environment deleted successfully");
utils.environment.byProjectId.invalidate({ projectId });
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments?.find(
(env) => env.name === "production",
);
if (productionEnv) {
router.push(
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
);
}
}
} catch (error) {
toast.error("Failed to delete environment");
}
};
const handleDuplicateEnvironment = async (environment: Environment) => {
try {
const result = await duplicateEnvironment.mutateAsync({
environmentId: environment.environmentId,
name: `${environment.name}-copy`,
description: environment.description,
});
toast.success("Environment duplicated successfully");
utils.project.one.invalidate({ projectId });
// Navigate to the new duplicated environment
router.push(
`/dashboard/project/${projectId}/environment/${result.environmentId}`,
);
} catch (error) {
toast.error("Failed to duplicate environment");
}
};
const openEditDialog = (environment: Environment) => {
setSelectedEnvironment(environment);
setName(environment.name);
setDescription(environment.description || "");
setIsEditDialogOpen(true);
};
const openDeleteDialog = (environment: Environment) => {
setSelectedEnvironment(environment);
setIsDeleteDialogOpen(true);
};
const currentEnv = environments?.find(
(env) => env.environmentId === currentEnvironmentId,
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-auto p-2 font-normal">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">/</span>
<span>{currentEnv?.name || "Select Environment"}</span>
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[300px]" align="start">
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<DropdownMenuSeparator />
{environments?.map((environment) => {
const servicesCount =
environment.mariadb.length +
environment.mongo.length +
environment.mysql.length +
environment.postgres.length +
environment.redis.length +
environment.applications.length +
environment.compose.length;
return (
<div
key={environment.environmentId}
className="flex items-center"
>
<DropdownMenuItem
className="flex-1 cursor-pointer"
onClick={() => {
router.push(
`/dashboard/project/${projectId}/environment/${environment.environmentId}`,
);
}}
>
<div className="flex items-center justify-between w-full">
<span>
{environment.name} ({servicesCount})
</span>
{environment.environmentId === currentEnvironmentId && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
<EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
}}
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables>
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
openEditDialog(environment);
}}
>
<PencilIcon className="h-3 w-3" />
</Button>
{canDeleteEnvironments && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
);
})}
<DropdownMenuSeparator />
{canCreateEnvironments && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Environment</DialogTitle>
<DialogDescription>
Create a new environment for your project.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Environment description"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateDialogOpen(false);
setName("");
setDescription("");
}}
>
Cancel
</Button>
<Button
onClick={handleCreateEnvironment}
disabled={!name.trim() || createEnvironment.isLoading}
>
{createEnvironment.isLoading ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Environment Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Environment</DialogTitle>
<DialogDescription>
Update the environment details.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="edit-description">Description (optional)</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Environment description"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsEditDialogOpen(false);
setSelectedEnvironment(null);
setName("");
setDescription("");
}}
>
Cancel
</Button>
<Button
onClick={handleUpdateEnvironment}
disabled={!name.trim() || updateEnvironment.isLoading}
>
{updateEnvironment.isLoading ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Environment Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Environment</DialogTitle>
<DialogDescription>
Are you sure you want to delete the environment "
{selectedEnvironment?.name}"? This action cannot be undone and
will also delete all services in this environment.
</DialogDescription>
</DialogHeader>
{haveServices && (
<AlertBlock type="warning">
This environment have active services, please delete them first.
</AlertBlock>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={
deleteEnvironment.isLoading ||
haveServices ||
!selectedEnvironment
}
>
{deleteEnvironment.isLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -25,7 +25,12 @@ const examples = [
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example });
@@ -48,34 +53,58 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
/>
</div>
{hasServers && (
{shouldShowServerDropdown && (
<div className="space-y-2">
<Label htmlFor="server-deploy">
Select the server where you want to deploy (optional)
</Label>
<Select
value={templateInfo.server?.serverId}
value={
templateInfo.server?.serverId ||
(!isCloud ? "dokploy" : undefined)
}
onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value);
if (server) {
if (value === "dokploy") {
setTemplateInfo({
...templateInfo,
server: server,
server: undefined,
});
} else {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
<div>
<h3 className="text-sm font-semibold">Configuration Files</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.configFiles.map((file, index) => (
{templateInfo?.details?.configFiles?.map((file, index) => (
<li key={index}>
<strong className="text-sm font-semibold">
{file.filePath}

View File

@@ -1,5 +1,5 @@
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Bot, PlusCircle, Trash2 } from "lucide-react";
import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -27,7 +27,6 @@ export interface StepProps {
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading, error, isError } =
api.ai.suggest.useMutation();
@@ -44,7 +43,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
.then((data) => {
setTemplateInfo({
...templateInfo,
suggestions: data,
suggestions: data || [],
});
})
.catch((error) => {
@@ -54,10 +53,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
});
}, [templateInfo.userInput]);
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
const handleEnvVariableChange = (
index: number,
field: "name" | "value",
@@ -308,11 +303,9 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
placeholder="Variable Name"
className="flex-1"
/>
<div className="flex-1 relative">
<div className="relative">
<Input
type={
showValues[env.name] ? "text" : "password"
}
type={"password"}
value={env.value}
onChange={(e) =>
handleEnvVariableChange(
@@ -323,19 +316,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
}
placeholder="Variable Value"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => toggleShowValue(env.name)}
>
{showValues[env.name] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
@@ -437,13 +417,14 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
<AccordionContent>
<ScrollArea className="w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.configFiles?.length > 0 ? (
{selectedVariant?.configFiles?.length &&
selectedVariant?.configFiles?.length > 0 ? (
<>
<div className="text-sm text-muted-foreground mb-4">
This template requires the following
configuration files to be mounted:
</div>
{selectedVariant.configFiles.map(
{selectedVariant?.configFiles?.map(
(config, index) => (
<div
key={index}

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles: Mount[];
configFiles?: Mount[];
}
interface Mount {
@@ -90,11 +90,11 @@ export const { useStepper, steps, Scoped } = defineStepper(
);
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const TemplateGenerator = ({ projectId }: Props) => {
export const TemplateGenerator = ({ environmentId }: Props) => {
const [open, setOpen] = useState(false);
const stepper = useStepper();
const { data: aiSettings } = api.ai.getAll.useQuery();
@@ -121,7 +121,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
const onSubmit = async () => {
await mutateAsync({
projectId,
environmentId: environmentId,
id: templateInfo.details?.id || "",
name: templateInfo?.details?.name || "",
description: templateInfo?.details?.shortDescription || "",
@@ -138,8 +138,9 @@ export const TemplateGenerator = ({ projectId }: Props) => {
.then(async () => {
toast.success("Compose Created");
setOpen(false);
await utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {

View File

@@ -15,6 +15,13 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export type Services = {
@@ -36,23 +43,35 @@ export type Services = {
};
interface DuplicateProjectProps {
projectId: string;
environmentId: string;
services: Services[];
selectedServiceIds: string[];
}
export const DuplicateProject = ({
projectId,
environmentId,
services,
selectedServiceIds,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "existing-environment"
const [selectedTargetProject, setSelectedTargetProject] =
useState<string>("");
const [selectedTargetEnvironment, setSelectedTargetEnvironment] =
useState<string>("");
const utils = api.useUtils();
const router = useRouter();
// Queries for project and environment selection
const { data: allProjects } = api.project.all.useQuery();
const { data: selectedProjectEnvironments } =
api.environment.byProjectId.useQuery(
{ projectId: selectedTargetProject },
{ enabled: !!selectedTargetProject },
);
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
@@ -61,6 +80,29 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
// If duplicating to same project+environment, invalidate the environment query
// to refresh the services list
if (duplicateType === "existing-environment") {
await utils.environment.one.invalidate({
environmentId: selectedTargetEnvironment,
});
await utils.environment.byProjectId.invalidate({
projectId: selectedTargetProject,
});
// If duplicating to the same environment we're currently viewing,
// also invalidate the current environment to refresh the services list
if (selectedTargetEnvironment === environmentId) {
await utils.environment.one.invalidate({ environmentId });
// Also invalidate the project query to refresh the project data
const projectId = router.query.projectId as string;
if (projectId) {
await utils.project.one.invalidate({ projectId });
}
}
}
toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"
@@ -68,7 +110,9 @@ export const DuplicateProject = ({
);
setOpen(false);
if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
router.push(
`/dashboard/project/${newProject?.projectId}/environment/${newProject?.environmentId}`,
);
}
},
onError: (error) => {
@@ -82,8 +126,20 @@ export const DuplicateProject = ({
return;
}
if (duplicateType === "existing-environment") {
if (!selectedTargetProject) {
toast.error("Please select a target project");
return;
}
if (!selectedTargetEnvironment) {
toast.error("Please select a target environment");
return;
}
}
// TODO: Update duplicate API to support targetProjectId and targetEnvironmentId
await duplicateProject({
sourceProjectId: projectId,
sourceEnvironmentId: selectedTargetEnvironment,
name,
description,
includeServices: true,
@@ -91,7 +147,7 @@ export const DuplicateProject = ({
id: service.id,
type: service.type,
})),
duplicateInSameProject: duplicateType === "same-project",
duplicateInSameProject: duplicateType === "existing-environment",
});
};
@@ -105,6 +161,8 @@ export const DuplicateProject = ({
setName("");
setDescription("");
setDuplicateType("new-project");
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
>
@@ -127,7 +185,14 @@ export const DuplicateProject = ({
<Label>Duplicate to</Label>
<RadioGroup
value={duplicateType}
onValueChange={setDuplicateType}
onValueChange={(value) => {
setDuplicateType(value);
// Reset selections when changing type
if (value !== "existing-environment") {
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
className="grid gap-2"
>
<div className="flex items-center space-x-2">
@@ -135,8 +200,13 @@ export const DuplicateProject = ({
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
<RadioGroupItem
value="existing-environment"
id="existing-environment"
/>
<Label htmlFor="existing-environment">
Existing environment
</Label>
</div>
</RadioGroup>
</div>
@@ -165,6 +235,74 @@ export const DuplicateProject = ({
</>
)}
{duplicateType === "existing-environment" && (
<>
{allProjects?.filter((p) => p.projectId !== environmentId)
.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4 text-center">
<p className="text-sm text-muted-foreground">
No other projects available. Create a new project first.
</p>
</div>
) : (
<>
{/* Step 1: Select Project */}
<div className="grid gap-2">
<Label>Target Project</Label>
<Select
value={selectedTargetProject}
onValueChange={(value) => {
setSelectedTargetProject(value);
setSelectedTargetEnvironment(""); // Reset environment when project changes
}}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter((p) => p.projectId !== environmentId)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Step 2: Select Environment (only show if project is selected) */}
{selectedTargetProject && (
<div className="grid gap-2">
<Label>Target Environment</Label>
<Select
value={selectedTargetEnvironment}
onValueChange={setSelectedTargetEnvironment}
>
<SelectTrigger>
<SelectValue placeholder="Select target environment" />
</SelectTrigger>
<SelectContent>
{selectedProjectEnvironments?.map((env) => (
<SelectItem
key={env.environmentId}
value={env.environmentId}
>
{env.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
</>
)}
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
@@ -187,18 +325,26 @@ export const DuplicateProject = ({
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
<Button
onClick={handleDuplicate}
disabled={
isLoading ||
(duplicateType === "new-project" && !name) ||
(duplicateType === "existing-environment" &&
(!selectedTargetProject || !selectedTargetEnvironment))
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
? "Duplicating to new project..."
: "Duplicating to environment..."}
</>
) : duplicateType === "new-project" ? (
"Duplicate project"
"Duplicate to new project"
) : (
"Duplicate services"
"Duplicate to environment"
)}
</Button>
</DialogFooter>

View File

@@ -0,0 +1,157 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Terminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { api } from "@/utils/api";
const updateEnvironmentSchema = z.object({
env: z.string().optional(),
});
type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
interface Props {
environmentId: string;
children?: React.ReactNode;
}
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.environment.update.useMutation();
const { data } = api.environment.one.useQuery(
{
environmentId,
},
{
enabled: !!environmentId,
},
);
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
},
resolver: zodResolver(updateEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
env: data.env ?? "",
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateEnvironment) => {
await mutateAsync({
env: formData.env || "",
environmentId: environmentId,
})
.then(() => {
toast.success("Environment variables updated successfully");
utils.environment.one.invalidate({ environmentId });
})
.catch(() => {
toast.error("Error updating the environment variables");
})
.finally(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{children ?? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<Terminal className="size-4" />
<span>Environment Variables</span>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle>Environment Variables</DialogTitle>
<DialogDescription>
Update the environment variables that are accessible to all services
in this environment.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
lineWrapping
language="properties"
wrapperClassName="h-[35rem] font-mono"
placeholder={`NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=your-api-key-here
`}
{...field}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -101,7 +101,18 @@ export const HandleProject = ({ projectId }: Props) => {
toast.success(projectId ? "Project Updated" : "Project Created");
setIsOpen(false);
if (!projectId) {
router.push(`/dashboard/project/${data?.projectId}`);
const projectIdToUse =
data && "project" in data ? data.project.projectId : undefined;
const environmentIdToUse =
data && "environment" in data
? data.environment.environmentId
: undefined;
if (environmentIdToUse && projectIdToUse) {
router.push(
`/dashboard/project/${projectIdToUse}/environment/${environmentIdToUse}`,
);
}
} else {
refetch();
}

View File

@@ -44,7 +44,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -96,22 +96,30 @@ export const ShowProjects = () => {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "services": {
const aTotalServices =
a.mariadb.length +
a.mongo.length +
a.mysql.length +
a.postgres.length +
a.redis.length +
a.applications.length +
a.compose.length;
const bTotalServices =
b.mariadb.length +
b.mongo.length +
b.mysql.length +
b.postgres.length +
b.redis.length +
b.applications.length +
b.compose.length;
const aTotalServices = a.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
const bTotalServices = b.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
comparison = aTotalServices - bTotalServices;
break;
}
@@ -158,12 +166,13 @@ export const ShowProjects = () => {
<>
<div className="flex max-sm:flex-col gap-4 items-center w-full">
<div className="flex-1 relative max-sm:w-full">
<Input
<FocusShortcutInput
placeholder="Filter projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
@@ -201,23 +210,40 @@ export const ShowProjects = () => {
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
const emptyServices = project?.environments
.map(
(env) =>
env.applications.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0,
)
.every(Boolean);
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
const totalServices = project?.environments
.map(
(env) =>
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length +
env.applications.length +
env.compose.length,
)
.reduce((acc, curr) => acc + curr, 0);
const haveServicesWithDomains = project?.environments
.map(
(env) =>
env.applications.length > 0 ||
env.compose.length > 0,
)
.some(Boolean);
return (
<div
@@ -225,11 +251,10 @@ export const ShowProjects = () => {
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}`}
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{project.applications.length > 0 ||
project.compose.length > 0 ? (
{haveServicesWithDomains ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -244,80 +269,90 @@ export const ShowProjects = () => {
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.applications.length > 0 && (
{project.environments.some(
(env) => env.applications.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={app.applicationStatus}
/>
</DropdownMenuLabel>
{project.environments.map((env) =>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
{project.compose.length > 0 && (
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
</DropdownMenuContent>

View File

@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -3,6 +3,10 @@
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { useRouter } from "next/router";
import React from "react";
import {
extractServices,
type Services,
} from "@/components/dashboard/settings/users/add-permissions";
import {
MariadbIcon,
MongodbIcon,
@@ -20,13 +24,34 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
// Extended Services type to include environmentId and environmentName for search navigation
type SearchServices = Services & {
environmentId: string;
environmentName: string;
};
const extractAllServicesFromProject = (project: any): SearchServices[] => {
const allServices: SearchServices[] = [];
// Iterate through all environments in the project
project.environments?.forEach((environment: any) => {
const environmentServices = extractServices(environment);
const servicesWithEnvironmentId: SearchServices[] = environmentServices.map(
(service) => ({
...service,
environmentId: environment.environmentId,
environmentName: environment.name,
}),
);
allServices.push(...servicesWithEnvironmentId);
});
return allServices;
};
export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
@@ -63,31 +88,42 @@ export const SearchCommand = () => {
</CommandEmpty>
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name}
</CommandItem>
))}
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
if (!productionEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
</CommandItem>
);
})}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Services"}>
<CommandList>
{data?.map((project) => {
const applications: Services[] = extractServices(project);
const applications: SearchServices[] =
extractAllServicesFromProject(project);
return applications.map((application) => (
<CommandItem
key={application.id}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
`/dashboard/project/${project.projectId}/environment/${application.environmentId}/services/${application.type}/${application.id}`,
);
setOpen(false);
}}
@@ -114,7 +150,8 @@ export const SearchCommand = () => {
<CircuitBoard className="h-6 w-6 mr-2" />
)}
<span className="flex-grow">
{project.name} / {application.name}{" "}
{project.name} / {application.environmentName} /{" "}
{application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div>
</span>
<div>

View File

@@ -65,6 +65,11 @@ export const AddCertificate = () => {
const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCertificate>({
defaultValues: {
@@ -85,7 +90,7 @@ export const AddCertificate = () => {
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
})
.then(async () => {
@@ -174,52 +179,70 @@ export const AddCertificate = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud && "(Optional)"}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud && "(Optional)"}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
<DialogFooter className="flex w-full flex-row !justify-end">

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -27,18 +26,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string().min(1, {
message: "App Password is required",
}),
name: z.string().min(1, { message: "Name is required" }),
username: z.string().min(1, { message: "Username is required" }),
email: z.string().email().optional(),
apiToken: z.string().min(1, { message: "API Token is required" }),
workspaceName: z.string().optional(),
});
@@ -47,14 +40,12 @@ type Schema = z.infer<typeof Schema>;
export const AddBitbucketProvider = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const _url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.user.get.useQuery();
const _router = useRouter();
const form = useForm<Schema>({
defaultValues: {
username: "",
password: "",
apiToken: "",
workspaceName: "",
},
resolver: zodResolver(Schema),
@@ -63,7 +54,8 @@ export const AddBitbucketProvider = () => {
useEffect(() => {
form.reset({
username: "",
password: "",
email: "",
apiToken: "",
workspaceName: "",
});
}, [form, isOpen]);
@@ -71,10 +63,11 @@ export const AddBitbucketProvider = () => {
const onSubmit = async (data: Schema) => {
await mutateAsync({
bitbucketUsername: data.username,
appPassword: data.password,
apiToken: data.apiToken,
bitbucketWorkspaceName: data.workspaceName || "",
authId: auth?.id || "",
name: data.name || "",
bitbucketEmail: data.email || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -113,37 +106,46 @@ export const AddBitbucketProvider = () => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<AlertBlock type="warning">
Bitbucket App Passwords are deprecated for new providers. Use
an API Token instead. Existing providers with App Passwords
will continue to work until 9th June 2026.
</AlertBlock>
<div className="mt-1 text-sm">
Manage tokens in
<Link
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
className="inline-flex items-center gap-1 ml-1"
>
<span>Bitbucket settings</span>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</div>
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li className="text-muted-foreground text-sm">
Click on Create API token with scopes
</li>
<li className="text-muted-foreground text-sm">
Select the expiration date (Max 1 year)
</li>
<li className="text-muted-foreground text-sm">
Select Bitbucket product.
</li>
</ul>
<p className="text-muted-foreground text-sm">
To integrate your Bitbucket account, you need to create a new
App Password in your Bitbucket settings. Follow these steps:
Select the following scopes:
</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Create new App Password{" "}
<Link
href="https://bitbucket.org/account/settings/app-passwords/new"
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</li>
<li>
When creating the App Password, ensure you grant the
following permissions:
<ul className="list-disc list-inside ml-4">
<li>Account: Read</li>
<li>Workspace membership: Read</li>
<li>Projects: Read</li>
<li>Repositories: Read</li>
<li>Pull requests: Read</li>
<li>Webhooks: Read and write</li>
</ul>
</li>
<li>
After creating, you'll receive an App Password. Copy it and
paste it below along with your Bitbucket username.
</li>
</ol>
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li>read:repository:bitbucket</li>
<li>read:pullrequest:bitbucket</li>
<li>read:webhook:bitbucket</li>
<li>read:workspace:bitbucket</li>
<li>write:webhook:bitbucket</li>
</ul>
<FormField
control={form.control}
name="name"
@@ -152,7 +154,7 @@ export const AddBitbucketProvider = () => {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Random Name eg(my-personal-account)"
placeholder="Your Bitbucket Provider, eg: my-personal-account"
{...field}
/>
</FormControl>
@@ -179,14 +181,27 @@ export const AddBitbucketProvider = () => {
<FormField
control={form.control}
name="password"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>App Password</FormLabel>
<FormLabel>Bitbucket Email</FormLabel>
<FormControl>
<Input placeholder="Your Bitbucket email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
placeholder="Paste your Bitbucket API token"
{...field}
/>
</FormControl>
@@ -200,7 +215,7 @@ export const AddBitbucketProvider = () => {
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace Name (Optional)</FormLabel>
<FormLabel>Workspace Name (optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization accounts"

View File

@@ -33,7 +33,10 @@ const Schema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
email: z.string().email().optional(),
workspaceName: z.string().optional(),
apiToken: z.string().optional(),
appPassword: z.string().optional(),
});
type Schema = z.infer<typeof Schema>;
@@ -60,19 +63,28 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
username: "",
email: "",
workspaceName: "",
apiToken: "",
appPassword: "",
},
resolver: zodResolver(Schema),
});
const username = form.watch("username");
const email = form.watch("email");
const workspaceName = form.watch("workspaceName");
const apiToken = form.watch("apiToken");
const appPassword = form.watch("appPassword");
useEffect(() => {
form.reset({
username: bitbucket?.bitbucketUsername || "",
email: bitbucket?.bitbucketEmail || "",
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
name: bitbucket?.gitProvider.name || "",
apiToken: bitbucket?.apiToken || "",
appPassword: bitbucket?.appPassword || "",
});
}, [form, isOpen, bitbucket]);
@@ -81,8 +93,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
bitbucketId,
gitProviderId: bitbucket?.gitProviderId || "",
bitbucketUsername: data.username,
bitbucketEmail: data.email || "",
bitbucketWorkspaceName: data.workspaceName || "",
name: data.name || "",
apiToken: data.apiToken || "",
appPassword: data.appPassword || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -121,6 +136,12 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
Update your Bitbucket authentication. Use API Token for
enhanced security (recommended) or App Password for legacy
support.
</p>
<FormField
control={form.control}
name="name"
@@ -154,6 +175,24 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (Required for API Tokens)</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Your Bitbucket email address"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="workspaceName"
@@ -171,6 +210,49 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
)}
/>
<div className="flex flex-col gap-2 border-t pt-4">
<h3 className="text-sm font-medium mb-2">
Authentication (Update to use API Token)
</h3>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token (Recommended)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Bitbucket API Token"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
App Password (Legacy - will be deprecated June 2026)
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Bitbucket App Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
@@ -180,7 +262,10 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
await testConnection({
bitbucketId,
bitbucketUsername: username,
bitbucketEmail: email,
workspaceName: workspaceName,
apiToken: apiToken,
appPassword: appPassword,
})
.then(async (message) => {
toast.info(`Message: ${message}`);

View File

@@ -30,6 +30,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
appName: z.string().min(1, {
message: "App Name is required",
}),
});
type Schema = z.infer<typeof Schema>;
@@ -55,6 +58,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
name: "",
appName: "",
},
resolver: zodResolver(Schema),
});
@@ -62,6 +66,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
useEffect(() => {
form.reset({
name: github?.gitProvider.name || "",
appName: github?.githubAppName || "",
});
}, [form, isOpen]);
@@ -70,6 +75,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
githubId,
name: data.name || "",
gitProviderId: github?.gitProviderId || "",
githubAppName: data.appName || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -124,6 +130,22 @@ export const EditGithubProvider = ({ githubId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input
placeholder="pp Name eg(my-personal)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-between gap-4 mt-4">
<Button

View File

@@ -157,7 +157,13 @@ export const ShowGitProviders = () => {
</div>
</div>
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1 items-center">
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
{!haveGithubRequirements && isGithub && (
<div className="flex flex-row gap-1 items-center">
<Badge

View File

@@ -38,7 +38,7 @@ import { api } from "@/utils/api";
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
apiKey: z.string(),
model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean(),
});
@@ -71,7 +71,7 @@ export const HandleAi = ({ aiId }: Props) => {
name: "",
apiUrl: "",
apiKey: "",
model: "gpt-3.5-turbo",
model: "",
isEnabled: true,
},
});
@@ -81,7 +81,7 @@ export const HandleAi = ({ aiId }: Props) => {
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "gpt-3.5-turbo",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}, [aiId, form, data]);
@@ -89,6 +89,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const { data: models, isLoading: isLoadingServerModels } =
api.ai.getModels.useQuery(
{
@@ -96,7 +97,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "",
},
{
enabled: !!apiUrl && !!apiKey,
enabled: !!apiUrl && (isOllama || !!apiKey),
onError: (error) => {
setError(`Failed to fetch models: ${error.message}`);
},
@@ -191,22 +192,29 @@ export const HandleAi = ({ aiId }: Props) => {
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
Your API key for authentication
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isOllama && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
/>
</FormControl>
<FormDescription>
Your API key for authentication
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{isLoadingServerModels && (
<span className="text-sm text-muted-foreground">
@@ -214,6 +222,12 @@ export const HandleAi = ({ aiId }: Props) => {
</span>
)}
{!isLoadingServerModels && !models?.length && (
<span className="text-sm text-muted-foreground">
No models available
</span>
)}
{!isLoadingServerModels && models && models.length > 0 && (
<FormField
control={form.control}

View File

@@ -12,6 +12,8 @@ import { toast } from "sonner";
import { z } from "zod";
import {
DiscordIcon,
GotifyIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -101,6 +103,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
decoration: z.boolean().default(true),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
accessToken: z.string().min(1, { message: "Access Token is required" }),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -121,9 +132,13 @@ export const notificationsMap = {
label: "Email",
},
gotify: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
icon: <GotifyIcon />,
label: "Gotify",
},
ntfy: {
icon: <NtfyIcon />,
label: "ntfy",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -155,6 +170,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -170,6 +187,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -266,6 +286,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} else if (notification.notificationType === "ntfy") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
}
} else {
form.reset();
@@ -278,6 +312,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
discord: discordMutation,
email: emailMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -366,6 +401,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
} else if (data.type === "ntfy") {
promise = ntfyMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
topic: data.topic,
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
}
if (promise) {
@@ -875,6 +925,83 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input placeholder="deployments" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access Token</FormLabel>
<FormControl>
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={3}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="3"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port <= 5) {
field.onChange(port);
}
}
}}
type="number"
/>
</FormControl>
<FormDescription>
Message priority (1-5, default: 3)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1024,7 +1151,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingGotify
isLoadingGotify ||
isLoadingNtfy
}
variant="secondary"
onClick={async () => {
@@ -1061,6 +1189,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: form.getValues("priority"),
decoration: form.getValues("decoration"),
});
} else if (type === "ntfy") {
await testNtfyConnection({
serverUrl: form.getValues("serverUrl"),
topic: form.getValues("topic"),
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
});
}
toast.success("Connection Success");
} catch {

View File

@@ -2,6 +2,8 @@ import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
GotifyIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -85,7 +87,12 @@ export const ShowNotifications = () => {
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
<GotifyIcon className="size-6" />
</div>
)}
{notification.notificationType === "ntfy" && (
<div className="flex items-center justify-center rounded-lg ">
<NtfyIcon className="size-6" />
</div>
)}

View File

@@ -33,7 +33,10 @@ import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
email: z.string(),
email: z
.string()
.email("Please enter a valid email address")
.min(1, "Email is required"),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
@@ -254,8 +257,16 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={field.value}
value={field.value}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -276,6 +287,71 @@ export const ProfileForm = () => {
</Avatar>
</FormLabel>
</FormItem>
<FormItem key="custom-upload">
<FormLabel className="[&:has([data-state=checked])>.upload-avatar]:border-primary [&:has([data-state=checked])>.upload-avatar]:border-1 [&:has([data-state=checked])>.upload-avatar]:p-px cursor-pointer">
<FormControl>
<RadioGroupItem
value="upload"
className="sr-only"
/>
</FormControl>
<div
className="upload-avatar h-12 w-12 rounded-full border border-dashed border-muted-foreground hover:border-primary transition-colors flex items-center justify-center bg-muted/50 hover:bg-muted overflow-hidden"
onClick={() =>
document
.getElementById("avatar-upload")
?.click()
}
>
{field.value?.startsWith("data:") ? (
<img
src={field.value}
alt="Custom avatar"
className="h-full w-full object-cover rounded-full"
/>
) : (
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
)}
</div>
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// max file size 2mb
if (file.size > 2 * 1024 * 1024) {
toast.error(
"Image size must be less than 2MB",
);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target
?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
}}
/>
</FormLabel>
</FormItem>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">

View File

@@ -97,11 +97,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
);
refetchDashboard();
})
.catch(() => {
toast.error(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
});
.catch(() => {});
}}
className="w-full cursor-pointer space-x-3"
>

View File

@@ -35,9 +35,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { extractServices } from "../users/add-permissions";
interface Props {
serverId?: string;
@@ -95,11 +95,13 @@ export const SetupMonitoring = ({ serverId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const extractServicesFromProjects = (projects: any[] | undefined) => {
const extractServicesFromProjects = () => {
if (!projects) return [];
const allServices = projects.flatMap((project) => {
const services = extractServices(project);
const services = project.environments.flatMap((env) =>
extractServices(env),
);
return serverId
? services
.filter((service) => service.serverId === serverId)
@@ -110,7 +112,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
return [...new Set(allServices)];
};
const services = extractServicesFromProjects(projects);
const services = extractServicesFromProjects();
const form = useForm<Schema>({
resolver: zodResolver(Schema),

View File

@@ -1,5 +1,6 @@
import type { findEnvironmentById } from "@dokploy/server/index";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -26,21 +27,147 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
type Environment = Omit<
Awaited<ReturnType<typeof findEnvironmentById>>,
"project"
>;
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
};
export const extractServices = (data: Environment | undefined) => {
const applications: Services[] =
data?.applications.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
appName: item.appName,
name: item.name,
type: "mariadb",
id: item.mariadbId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const postgres: Services[] =
data?.postgres.map((item) => ({
appName: item.appName,
name: item.name,
type: "postgres",
id: item.postgresId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mongo: Services[] =
data?.mongo.map((item) => ({
appName: item.appName,
name: item.name,
type: "mongo",
id: item.mongoId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const redis: Services[] =
data?.redis.map((item) => ({
appName: item.appName,
name: item.name,
type: "redis",
id: item.redisId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mysql: Services[] =
data?.mysql.map((item) => ({
appName: item.appName,
name: item.name,
type: "mysql",
id: item.mysqlId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const compose: Services[] =
data?.compose.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
applications.push(
...mysql,
...redis,
...mongo,
...postgres,
...mariadb,
...compose,
);
applications.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return applications;
};
const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false),
canDeleteEnvironments: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false),
canAccessToSSHKeys: z.boolean().optional().default(false),
canAccessToGitProviders: z.boolean().optional().default(false),
canCreateEnvironments: z.boolean().optional().default(false),
});
type AddPermissions = z.infer<typeof addPermissions>;
@@ -50,6 +177,7 @@ interface Props {
}
export const AddUserPermissions = ({ userId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.one.useQuery(
@@ -67,28 +195,43 @@ export const AddUserPermissions = ({ userId }: Props) => {
const form = useForm<AddPermissions>({
defaultValues: {
accessedProjects: [],
accessedEnvironments: [],
accessedServices: [],
canDeleteEnvironments: false,
canCreateProjects: false,
canCreateServices: false,
canDeleteProjects: false,
canDeleteServices: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
canCreateEnvironments: false,
},
resolver: zodResolver(addPermissions),
});
useEffect(() => {
if (data) {
if (data && isOpen) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices,
canDeleteEnvironments: data.canDeleteEnvironments || false,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
canCreateEnvironments: data.canCreateEnvironments,
});
}
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
}, [form, form.reset, data, isOpen]);
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
@@ -97,24 +240,28 @@ export const AddUserPermissions = ({ userId }: Props) => {
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteEnvironments: data.canDeleteEnvironments,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
canCreateEnvironments: data.canCreateEnvironments,
})
.then(async () => {
toast.success("Permissions updated");
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the permissions");
});
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -216,6 +363,46 @@ export const AddUserPermissions = ({ userId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environments</FormLabel>
<FormDescription>
Allow the user to create environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Environments</FormLabel>
<FormDescription>
Allow the user to delete environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
@@ -332,89 +519,317 @@ export const AddUserPermissions = ({ userId }: Props) => {
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = extractServices(item);
<div className="grid md:grid-cols-1 gap-4">
{projects?.map((project, projectIndex) => {
return (
<FormField
key={`project-${index}`}
key={`project-${projectIndex}`}
control={form.control}
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
key={item.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
key={project.projectId}
className="flex flex-col items-start rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
{/* Project Header */}
<div className="flex flex-row gap-4 items-center w-full">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
project.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
if (checked) {
// Add the project
field.onChange([
...(field.value || []),
project.projectId,
]);
} else {
// Remove the project
field.onChange(
field.value?.filter(
(value) =>
value !== project.projectId,
),
);
// Also remove all environments and services from this project
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
const currentServices =
form.getValues(
"accessedServices",
) || [];
// Get all environment IDs from this project
const projectEnvIds =
project.environments.map(
(env) => env.environmentId,
);
// Get all service IDs from this project
const projectServiceIds =
project.environments.flatMap(
(env) =>
extractServices(env).map(
(service) => service.id,
),
);
// Remove environments and services from this project
form.setValue(
"accessedEnvironments",
currentEnvs.filter(
(envId) =>
!projectEnvIds.includes(envId),
),
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!projectServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
<FormLabel className="text-base font-semibold text-primary">
{project.name}
</FormLabel>
</div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground">
No services found
</p>
)}
{applications?.map((item, index) => (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedServices"
render={({ field }) => {
{/* Environments */}
<div className="ml-6 w-full space-y-3">
{project.environments.length === 0 && (
<p className="text-sm text-muted-foreground">
No environments found
</p>
)}
{project.environments.map(
(environment, envIndex) => {
const services =
extractServices(environment);
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
<div
key={`env-${envIndex}`}
className="border-l-2 border-muted pl-4"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
item.id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.id,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.id,
),
{/* Environment Header with Checkbox */}
<FormField
key={`env-${envIndex}`}
control={form.control}
name="accessedEnvironments"
render={({ field: envField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 mb-2">
<FormControl>
<Checkbox
checked={envField.value?.includes(
environment.environmentId,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the environment
envField.onChange([
...(envField.value ||
[]),
environment.environmentId,
]);
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the environment
envField.onChange(
envField.value?.filter(
(value) =>
value !==
environment.environmentId,
),
);
// Also remove all services from this environment
const currentServices =
form.getValues(
"accessedServices",
) || [];
const environmentServiceIds =
services.map(
(service) =>
service.id,
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!environmentServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<FormLabel className="text-sm font-medium text-foreground cursor-pointer">
{environment.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({services.length} services)
</span>
</div>
</FormItem>
)}
/>
{/* Services */}
<div className="ml-4 space-y-2">
{services.length === 0 && (
<p className="text-xs text-muted-foreground">
No services found
</p>
)}
{services.map(
(service, serviceIndex) => (
<FormField
key={`service-${serviceIndex}`}
control={form.control}
name="accessedServices"
render={({
field: serviceField,
}) => {
return (
<FormItem
key={service.id}
className="flex flex-row items-center space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={serviceField.value?.includes(
service.id,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the service
serviceField.onChange(
[
...(serviceField.value ||
[]),
service.id,
],
);
// Auto-select the environment if not already selected
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
if (
!currentEnvs.includes(
environment.environmentId,
)
) {
form.setValue(
"accessedEnvironments",
[
...currentEnvs,
environment.environmentId,
],
);
}
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the service
serviceField.onChange(
serviceField.value?.filter(
(value) =>
value !==
service.id,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
service.type ===
"application"
? "bg-green-500"
: service.type ===
"compose"
? "bg-purple-500"
: "bg-orange-500"
}`}
/>
<FormLabel className="text-sm text-muted-foreground cursor-pointer">
{service.name}
</FormLabel>
<span className="text-xs text-muted-foreground/70 capitalize">
({service.type})
</span>
</div>
</FormItem>
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
}}
/>
),
)}
</div>
</div>
);
}}
/>
))}
},
)}
</div>
</FormItem>
);
}}

View File

@@ -5,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -76,6 +77,9 @@ export const WebDomain = () => {
resolver: zodResolver(addServerDomain),
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
const host = data?.user?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
@@ -119,6 +123,19 @@ export const WebDomain = () => {
</div>
</CardHeader>
<CardContent className="space-y-2 py-6 border-t">
{/* Warning for GitHub webhook URL changes */}
{hasChanged && (
<AlertBlock type="warning">
<div className="space-y-2">
<p className="font-medium"> Important: URL Change Impact</p>
<p>
If you change the Dokploy Server URL make sure to update
your Github Apps to keep the auto-deploy working and preview
deployments working.
</p>
</div>
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -13,7 +13,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -40,18 +39,26 @@ interface Props {
appName: string;
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
}
export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
export const DockerTerminalModal = ({
children,
appName,
serverId,
appType,
}: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -83,7 +90,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-h-[85vh] sm:max-w-7xl"
className="max-h-[85vh] sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader>
@@ -92,7 +99,6 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
Easy way to access to docker container
</DialogDescription>
</DialogHeader>
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (

View File

@@ -88,3 +88,121 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const GotifyIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 500 500"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<style>
{`
.gotify-st0{fill:#DDCBA2;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st1{fill:#71CAEE;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st2{fill:#FFFFFF;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st3{fill:#888E93;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st4{fill:#F0F0F0;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st5{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st8{fill:#FFFFFF;}
`}
</style>
<linearGradient
id="gotify-gradient"
x1="265"
y1="280"
x2="275"
y2="302"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#71CAEE" />
<stop offset="0.04" stopColor="#83CAE2" />
<stop offset="0.12" stopColor="#9FCACE" />
<stop offset="0.21" stopColor="#B6CBBE" />
<stop offset="0.31" stopColor="#C7CBB1" />
<stop offset="0.44" stopColor="#D4CBA8" />
<stop offset="0.61" stopColor="#DBCBA3" />
<stop offset="1" stopColor="#DDCBA2" />
</linearGradient>
</defs>
<g transform="matrix(2.33,0,0,2.33,-432,-323)">
<g transform="translate(-25,26)">
<path
className="gotify-st1"
d="m258.9,119.7c-3,-0.9-6,-1.8-9,-2.7-4.6,-1.4-9.2,-2.8-14,-2.5-2.8,0.2-6.1,1.3-6.9,4-0.6,2-1.6,7.3-1.3,7.9 1.5,3.4 13.9,6.7 18.3,6.7"
/>
<path d="m392.6,177.9c-1.4,1.4-2.2,3.5-2.5,5.5-0.2,1.4-0.1,3 0.5,4.3 0.6,1.3 1.8,2.3 3.1,3 1.3,0.6 2.8,0.9 4.3,0.9 1.1,0 2.3,-0.1 3.1,-0.9 0.6,-0.7 0.8,-1.6 0.9,-2.5 0.2,-2.3-0.1,-4.7-0.9,-6.9-0.4,-1.1-0.9,-2.3-1.8,-3.1-1.7,-1.8-4.5,-2.2-6.4,-0.5-0.1,0-0.2,0.1-0.3,0.2z" />
<path
className="gotify-st2"
d="m358.5,164.2c-1,-1 0,-2.7 1,-3.7 5.8,-5.2 15.1,-4.6 21.8,-0.6 10.9,6.6 15.6,19.9 17.2,32.5 0.6,5.2 0.9,10.6-0.5,15.7-1.4,5.1-4.6,9.9-9.3,12.1-1.1,0.5-2.3,0.9-3.4,0.5-1.1,-0.4-1.9,-1.8-1.2,-2.8-9.4,-13.6-19,-26.8-20.9,-43.2-0.5,-4.1-1.8,-7.4-4.7,-10.5z"
/>
<path
className="gotify-st1"
d="m220.1,133c34.6,-18 79.3,-19.6 112.2,-8.7 23.7,7.9 41.3,26.7 49.5,50 7.1,20.6 7.1,43.6 3,65.7-7.5,40.2-26.2,77.9-49,112.6-12.6,19-24.6,36-44.2,48.5-38.7,24.6-88.9,22.1-129.3,11.5-19.5,-5.1-38.4,-17.3-44.3,-37.3-3.8,-12.8-2.1,-27.6 4.6,-40 13.5,-24.8 46.2,-38.4 50.8,-67.9 1.4,-8.7-0.3,-17.3-1.6,-25.7-3.8,-23.4-5.4,-45.8 6.7,-68.7 9.5,-17.7 24.3,-31 41.7,-40z"
/>
<path
className="gotify-st2"
d="m264.5,174.9c-0.5,0.5-0.9,1-1.3,1.6-9,11.6-12,27.9-9.3,42.1 1.7,9 5.9,17.9 13.2,23.4 19.3,14.6 51.5,13.5 68.4,-1.5 24.4,-21.7 13,-67.6-14,-78.8-17.6,-7.2-43.7,-1.6-57,13.2z"
/>
<path
className="gotify-st2"
d="m382.1,237.1c1.4,-0.1 2.9,-0.1 4.3,0.1 0.3,0 0.7,0.1 1,0.4 0.2,0.3 0.4,0.7 0.5,1.1 1,3.9 0.5,8.2 0.1,12.4-0.1,0.9-0.2,1.8-0.6,2.6-1,2.1-3.1,2.7-4.7,2.7-0.1,0-0.2,0-0.3,-0.1-0.3,-0.2-0.3,-0.7-0.2,-1.2 0.3,-5.9-0.1,-11.9-0.1,-18z"
/>
<path
className="gotify-st2"
d="m378.7,236.8c-1.4,0.4-2.5,2-2.8,4.4-0.5,4.4-0.7,8.9-0.5,13.4 0,0.9 0.1,1.9 0.5,2.4 0.2,0.3 0.5,0.4 0.8,0.4 1.6,0.3 4.1,-0.6 5.6,-1 0,0 0,-5.2-0.1,-8-0.1,-2.8-0.1,-6.1-0.2,-8.9 0,-0.6 0,-1.5 0,-2.2 0.1,-0.7-2.6,-0.7-3.3,-0.5z"
/>
<path
className="gotify-st0"
d="m358.3,231.8c-0.3,2.2 0.1,4.7 1.7,7.4 2.6,4.4 7,6.1 11.9,5.8 8.9,-0.6 25.3,-5.4 27.5,-15.7 0.6,-3-0.3,-6.1-2.2,-8.5-6.2,-7.8-17.8,-5.7-25.6,-2-5.9,2.7-12.4,7-13.3,13z"
/>
<path
className="gotify-st3"
d="m386.4,208.6c2.2,1.4 3.7,3.8 4,7 0.3,3.6-1.4,7.5-5,8.8-2.9,1.1-6.2,0.6-9.1,-0.4-2.9,-1-5.8,-2.8-6.8,-5.7-0.7,-2-0.3,-4.3 0.7,-6.1 1.1,-1.8 2.8,-3.2 4.7,-4.1 3.9,-1.8 8.4,-1.6 11.5,0.5z"
/>
<path
className="gotify-st0"
d="m414.7,262.6c2.4,0.6 4.8,2.1 5.6,4.4 0.8,2.3 0.1,4.9-1.6,6.7-1.7,1.8-4.2,2.5-6.6,2.5-0.8,0-1.7,-0.1-2.4,-0.5-2.5,-1.1-3.5,-4-4.2,-6.6-1.8,-6.8 3.6,-7.8 9.2,-6.5z"
/>
<path
className="gotify-st4"
d="m267.1,284.7c2.3,-4.5 141.3,-36.2 144.7,-31.6 3.4,4.5 15.8,88.2 9,90.4-6.8,2.3-119.8,37.3-126.6,35-6.8,-2.3-29.4,-89.3-27.1,-93.8z"
/>
<path
className="gotify-st5"
d="m294.2,378.5c0,0 54.3,-74.6 59.9,-76.9 5.7,-2.3 67.3,41.3 67.3,41.3"
/>
<path
className="gotify-st4"
d="m267,287.7c0,0 86,38.8 91.6,36.6 5.7,-2.3 53.1,-71.2 53.1,-71.2"
/>
<path
fill="url(#gotify-gradient)"
d="m261.9,283.5c-0.1,4.2 4.3,7.3 8.4,7.6 4.1,0.3 8.2,-1.3 12.2,-2.6 1.4,-0.4 2.9,-0.8 4.2,-0.2 1.8,0.9 2.7,4.1 1.8,5.9-0.9,1.8-3.4,3.5-5.3,4.4-6.5,3-12.9,3.6-19.9,2-5.3,-1.2-11.3,-4.3-13,-13.5"
/>
<path d="m318.4,198.4c-2,-0.3-4.1,0.1-5.9,1.3-3.2,2.1-4.7,6.2-4.7,9.9 0,1.9 0.4,3.8 1.4,5.3 1.2,1.7 3.1,2.9 5.2,3.4 3.4,0.8 8.2,0.7 10.5,-2.5 1,-1.5 1.4,-3.3 1.5,-5.1 0.5,-5.7-1.8,-11.4-8,-12.3z" />
<path
className="gotify-st8"
d="m320.4,203.3c0.9,0.3 1.7,0.8 2.1,1.7 0.4,0.8 0.4,1.7 0.3,2.5-0.1,1-0.6,2-1.5,2.7-0.7,0.5-1.7,0.7-2.6,0.5-0.9,-0.2-1.7,-0.8-2.2,-1.6-1.1,-1.6-0.9,-4.4 0.9,-5.5 0.9,-0.4 2,-0.6 3,-0.3z"
/>
</g>
</g>
</svg>
);
};
export const NtfyIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 24 24"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M12.597 13.693v2.156h6.205v-2.156ZM5.183 6.549v2.363l3.591 1.901 0.023 0.01 -0.023 0.009 -3.591 1.901v2.35l0.386 -0.211 5.456 -2.969V9.729ZM3.659 2.037C1.915 2.037 0.42 3.41 0.42 5.154v0.002L0.438 18.73 0 21.963l5.956 -1.583h14.806c1.744 0 3.238 -1.374 3.238 -3.118V5.154c0 -1.744 -1.493 -3.116 -3.237 -3.117h-0.001zm0 2.2h17.104c0.613 0.001 1.037 0.447 1.037 0.917v12.108c0 0.47 -0.424 0.916 -1.038 0.916H5.633l-3.026 0.915 0.031 -0.179 -0.017 -13.76c0 -0.47 0.424 -0.917 1.038 -0.917z"
/>
</svg>
);
};

View File

@@ -11,11 +11,20 @@ interface Props {
export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: isUserSubscribed } = api.settings.isUserSubscribed.useQuery(
undefined,
{
enabled: isCloud === true,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
return (
<>
<Page>{children}</Page>
{isCloud === true && (
{isCloud === true && isUserSubscribed === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
)}

View File

@@ -13,7 +13,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
interface Props {
list: {
name: string;
href: string;
href?: string;
}[];
}
@@ -29,11 +29,11 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
{list.map((item, index) => (
<Fragment key={item.name}>
<BreadcrumbItem className="block">
<BreadcrumbLink href={item.href} asChild={!!item.href}>
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
{item.href ? (
<Link href={item.href}>{item.name}</Link>
<Link href={item?.href}>{item?.name}</Link>
) : (
item.name
item?.name
)}
</BreadcrumbLink>
</BreadcrumbItem>

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
type Props = React.ComponentPropsWithoutRef<typeof Input>;
export const FocusShortcutInput = (props: Props) => {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey;
if (!isMod || e.key.toLowerCase() !== "k") return;
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName;
if (
target.isContentEditable ||
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target.getAttribute("role") === "textbox"
)
return;
}
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return <Input {...props} ref={inputRef} />;
};

View File

@@ -7,7 +7,14 @@ import {
import { cn } from "@/lib/utils";
interface Props {
status: "running" | "error" | "done" | "idle" | undefined | null;
status:
| "running"
| "error"
| "done"
| "idle"
| "cancelled"
| undefined
| null;
className?: string;
}
@@ -34,6 +41,14 @@ export const StatusTooltip = ({ status, className }: Props) => {
className={cn("size-3.5 rounded-full bg-green-500", className)}
/>
)}
{status === "cancelled" && (
<div
className={cn(
"size-3.5 rounded-full bg-muted-foreground",
className,
)}
/>
)}
{status === "running" && (
<div
className={cn("size-3.5 rounded-full bg-yellow-500", className)}
@@ -46,6 +61,7 @@ export const StatusTooltip = ({ status, className }: Props) => {
{status === "error" && "Error"}
{status === "done" && "Done"}
{status === "running" && "Running"}
{status === "cancelled" && "Cancelled"}
</span>
</TooltipContent>
</Tooltip>

View File

@@ -1,25 +1,16 @@
import copy from "copy-to-clipboard";
import { Clipboard, EyeIcon, EyeOffIcon } from "lucide-react";
import { useRef, useState } from "react";
import { Clipboard } from "lucide-react";
import { useRef } from "react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { Input, type InputProps } from "../ui/input";
export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const togglePasswordVisibility = () => {
setIsPasswordVisible((prevVisibility) => !prevVisibility);
};
return (
<div className="flex w-full items-center space-x-2">
<Input
ref={inputRef}
type={isPasswordVisible ? "text" : "password"}
{...props}
/>
<Input ref={inputRef} type={"password"} {...props} />
<Button
variant={"secondary"}
onClick={() => {
@@ -29,13 +20,13 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
>
<Clipboard className="size-4 text-muted-foreground" />
</Button>
<Button onClick={togglePasswordVisibility} variant={"secondary"}>
{/* <Button onClick={togglePasswordVisibility} variant={"secondary"}>
{isPasswordVisible ? (
<EyeOffIcon className="size-4 text-muted-foreground" />
) : (
<EyeIcon className="size-4 text-muted-foreground" />
)}
</Button>
</Button> */}
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { EyeIcon, EyeOffIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
@@ -8,18 +9,39 @@ export interface InputProps
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, type, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
const isPassword = type === "password";
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
return (
<>
<input
type={type}
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
className,
<div className="relative w-full">
<input
type={inputType}
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
isPassword && "pr-10", // Add padding for the eye icon
className,
)}
ref={ref}
{...props}
/>
{isPassword && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
)}
ref={ref}
{...props}
/>
</div>
{errorMessage && (
<span className="text-sm text-red-600 text-secondary-foreground">
{errorMessage}

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewLabels" text[];

View File

@@ -0,0 +1,147 @@
CREATE TABLE "environment" (
"environmentId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"createdAt" text NOT NULL,
"projectId" text NOT NULL
);
ALTER TABLE "environment" ADD CONSTRAINT "environment_projectId_project_projectId_fk" FOREIGN KEY ("projectId") REFERENCES "public"."project"("projectId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- Insertar un ambiente "production" para cada proyecto existente
INSERT INTO "environment" ("environmentId", "name", "description", "createdAt", "projectId")
SELECT
-- Generar un ID único para cada ambiente usando el projectId como base
'env_prod_' || "projectId" || '_' || EXTRACT(EPOCH FROM NOW())::text,
'production',
'Production environment',
NOW()::text,
"projectId"
FROM "project"
WHERE "projectId" NOT IN (
SELECT DISTINCT "projectId"
FROM "environment"
WHERE "name" = 'production'
);
ALTER TABLE "application" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "environmentId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "environmentId" text;--> statement-breakpoint
-- Step 3: Update all services to point to their project's production environment
-- Update applications
UPDATE "application"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "application"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update compose
UPDATE "compose"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "compose"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update mariadb
UPDATE "mariadb"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "mariadb"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update mongo
UPDATE "mongo"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "mongo"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update mysql
UPDATE "mysql"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "mysql"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update postgres
UPDATE "postgres"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "postgres"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
-- Update redis
UPDATE "redis"
SET "environmentId" = (
SELECT e."environmentId"
FROM "environment" e
WHERE e."projectId" = "redis"."projectId"
AND e."name" = 'production'
LIMIT 1
);--> statement-breakpoint
--> statement-breakpoint
ALTER TABLE "application" DROP CONSTRAINT "application_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "postgres" DROP CONSTRAINT "postgres_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "mariadb" DROP CONSTRAINT "mariadb_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "mongo" DROP CONSTRAINT "mongo_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "mysql" DROP CONSTRAINT "mysql_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "redis" DROP CONSTRAINT "redis_projectId_project_projectId_fk";
--> statement-breakpoint
ALTER TABLE "compose" DROP CONSTRAINT "compose_projectId_project_projectId_fk";
--> statement-breakpoint
-- Step 4: Make environmentId columns NOT NULL
ALTER TABLE "application" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "compose" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "mariadb" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "mongo" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "mysql" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "postgres" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "redis" ALTER COLUMN "environmentId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "postgres" ADD CONSTRAINT "postgres_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mariadb" ADD CONSTRAINT "mariadb_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mongo" ADD CONSTRAINT "mongo_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mysql" ADD CONSTRAINT "mysql_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "redis" ADD CONSTRAINT "redis_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compose" ADD CONSTRAINT "compose_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "postgres" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "mariadb" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "mongo" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "mysql" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "redis" DROP COLUMN "projectId";--> statement-breakpoint
ALTER TABLE "compose" DROP COLUMN "projectId";

View File

@@ -0,0 +1 @@
ALTER TABLE "environment" ADD COLUMN "env" text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "accessedEnvironments" text[] DEFAULT ARRAY[]::text[] NOT NULL;

View File

@@ -0,0 +1,11 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'ntfy';--> statement-breakpoint
CREATE TABLE "ntfy" (
"ntfyId" text PRIMARY KEY NOT NULL,
"serverUrl" text NOT NULL,
"topic" text NOT NULL,
"accessToken" text NOT NULL,
"priority" integer DEFAULT 3 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "ntfyId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_ntfyId_ntfy_ntfyId_fk" FOREIGN KEY ("ntfyId") REFERENCES "public"."ntfy"("ntfyId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "bitbucket" ADD COLUMN "apiToken" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "bitbucket" ADD COLUMN "bitbucketEmail" text;

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."deploymentStatus" ADD VALUE 'cancelled';

View File

@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "stopGracePeriodSwarm" bigint;

View File

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

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