Compare commits

..

172 Commits

Author SHA1 Message Date
Mauricio Siu
dadef000d5 Merge pull request #2902 from Dokploy/feat/add-cloud-tracking
feat(tracking): integrate HubSpot tracking functionality and reintrod…
2025-10-26 01:56:10 -06:00
Mauricio Siu
2cda9821a5 feat(tracking): integrate HubSpot tracking functionality and reintroduce cancell-deployments export 2025-10-26 01:54:05 -06:00
Mauricio Siu
a0868ad57c chore(package): bump version to v0.25.6 2025-10-26 01:32:36 -06:00
Mauricio Siu
d4f574aa3f Merge pull request #2900 from Dokploy/2777-bug-report-environment-variables-not-loaded-in-docker-compose-raw-mode
2777 bug report environment variables not loaded in docker compose raw mode
2025-10-26 01:30:44 -06:00
Mauricio Siu
07368ff8c6 fix(compose): add default compose path for raw source type in file editor 2025-10-26 01:29:34 -06:00
Mauricio Siu
102a7a00b8 fix(compose): update environment file path handling to support raw source type 2025-10-26 01:23:14 -06:00
Mauricio Siu
25a6a5bec6 Merge pull request #2899 from Dokploy/2793-watch-paths-dont-work-with-bitbucket-workspace-specified
fix(api): update Bitbucket API URL construction to use a unified user…
2025-10-26 01:01:19 -06:00
Mauricio Siu
011792e26b fix(api): update Bitbucket API URL construction to use a unified username variable 2025-10-26 00:57:06 -06:00
Mauricio Siu
a527bafad8 Merge pull request #2740 from kirill-dev-pro/pass-dokploy-preview-url-at-build-time
Pass `DOKPLOY_DEPLOY_URL` as build time argument so it can be used during build
2025-10-25 13:18:56 -06:00
Mauricio Siu
14e154bece fix(application): use template literals for dynamic content in deployment comments 2025-10-25 13:18:09 -06:00
Mauricio Siu
e5aeff6106 fix(application): update deployment comment syntax to use template literals for dynamic content 2025-10-25 13:17:27 -06:00
Mauricio Siu
f6ff90eed9 fix(application): correct log path variable usage and update label syntax in getApplicationStats function 2025-10-25 13:16:25 -06:00
Mauricio Siu
f34a65cf14 Merge branch 'canary' into pass-dokploy-preview-url-at-build-time 2025-10-25 13:15:25 -06:00
Mauricio Siu
8c0db75e1e Merge pull request #2795 from Harikrishnan1367709/Support-query-parameter-auth-(-key=)-for-Gemini-API-in-AI-Providers-#2775
feat(ai):Support Gemini query-param auth for model listing-#2775
2025-10-25 13:12:45 -06:00
Mauricio Siu
b3c6645b35 Merge pull request #2837 from Harikrishnan1367709/Loading-State-Applied-Globally-for-All-Volume-Backups-#2836
fix(volume-backups):Volume Backups Loading State to Track Per Backup -#2836
2025-10-25 13:11:53 -06:00
Mauricio Siu
0ff0695b7f refactor(volume-backups): rename backupServerId to serverId for clarity in volume backup component 2025-10-25 13:10:02 -06:00
Mauricio Siu
6a4ef1153f fix(volume-backups): adjust layout for volume backups display and reintroduce toast notifications 2025-10-25 13:07:15 -06:00
Mauricio Siu
a65262b45e Merge branch 'canary' into Loading-State-Applied-Globally-for-All-Volume-Backups-#2836 2025-10-25 13:06:24 -06:00
Mauricio Siu
75a66826f2 Merge pull request #2840 from imran-vz/feat/recreate-2fa-backup-codes
feat: Recreate 2fa backup codes
2025-10-25 13:03:44 -06:00
Mauricio Siu
a5eeb74831 feat(2fa): add functionality to download and copy backup codes to clipboard 2025-10-25 13:01:25 -06:00
Mauricio Siu
3dad8b4a54 Merge branch 'canary' into feat/recreate-2fa-backup-codes 2025-10-25 12:49:52 -06:00
Mauricio Siu
fd94a14d85 Merge pull request #2674 from ischanx/feat-lark-webhook
feat(notifications): add lark webhook
2025-10-25 12:35:38 -06:00
Mauricio Siu
d7e0413ed9 feat(notification): add 'lark' notification type and create associated table; update notification schema 2025-10-25 12:10:39 -06:00
Mauricio Siu
f4748bdd11 Merge branch 'canary' into feat-lark-webhook 2025-10-25 12:09:04 -06:00
Mauricio Siu
5a50d4bfc7 chore: remove lark webhook SQL files and related journal entries 2025-10-25 12:06:55 -06:00
Mauricio Siu
d1130c4554 Merge pull request #2893 from hl9020/feature/copy-logs-to-clipboard
feat: Add copy to clipboard functionality for deployment and runtime logs
2025-10-25 11:52:46 -06:00
Mauricio Siu
fd2775e32a feat(copy-logs): simplify clipboard copy functionality using copy-to-clipboard library 2025-10-25 11:51:44 -06:00
Mauricio Siu
51003276bc Merge branch 'canary' into feature/copy-logs-to-clipboard 2025-10-25 11:49:07 -06:00
Mauricio Siu
6fb3584283 Merge pull request #2810 from CorentinMre/fix/memory-monitoring-actual-usage
fix: use actual memory usage excluding cache/buffers in monitoring
2025-10-25 11:47:32 -06:00
Mauricio Siu
997dd784a5 Merge pull request #2862 from Harikrishnan1367709/Add-Ctrl+S/CMD+S-keyboard-shortcuts-to-save-.env-file-#2845
Add Ctrl+S/CMD+S keyboard shortcuts to save .env files -#2845
2025-10-25 11:43:30 -06:00
Mauricio Siu
60d1bc4a6d Merge pull request #2898 from Dokploy/2896-file-mount-box-width-expands-with-the-textbox
fix(volumes): update FormItem class for better layout and adjust inpu…
2025-10-25 11:26:22 -06:00
Mauricio Siu
daa8184c30 fix(volumes): update FormItem class for better layout and adjust input class for consistency 2025-10-25 11:24:11 -06:00
Mauricio Siu
68be333b04 Merge pull request #2831 from Harikrishnan1367709/All-scheduled-tasks-show-loading-state-when-running-a-single-task-manually-#2829
fix(schedules): scheduler loading state to track per scheduler -#2829
2025-10-25 01:06:20 -06:00
Mauricio Siu
2723703f60 Merge branch 'canary' into All-scheduled-tasks-show-loading-state-when-running-a-single-task-manually-#2829 2025-10-25 01:04:36 -06:00
Mauricio Siu
d83783c620 Merge pull request #2820 from Harikrishnan1367709/Move-environment-variables-icon-outside-dropdown-#2755
feat(ui): Move Environment Variables Icon Outside Dropdown -#2755
2025-10-25 00:58:33 -06:00
Mauricio Siu
ea9c76c1df refactor: replace SquareTerminal icon with a Button component in EnvironmentVariables 2025-10-25 00:58:12 -06:00
Mauricio Siu
32c302e9ce Merge branch 'canary' into Move-environment-variables-icon-outside-dropdown-#2755 2025-10-25 00:54:00 -06:00
Mauricio Siu
9f99185628 Merge pull request #2745 from iksaku/feat/docker-build-secrets
feat(docker): Build-time Secrets
2025-10-25 00:32:27 -06:00
Mauricio Siu
05b20193c2 fix(docker): escape single quotes in secret values for Docker command 2025-10-25 00:25:25 -06:00
autofix-ci[bot]
88a8c060db [autofix.ci] apply automated fixes 2025-10-25 05:55:09 +00:00
Mauricio Siu
71c01ff30f feat: add previewBuildSecrets and buildSecrets columns to application table 2025-10-24 23:42:55 -06:00
Mauricio Siu
babc1c033e Merge branch 'canary' into feat/docker-build-secrets 2025-10-24 23:42:23 -06:00
Mauricio Siu
b34334701b refactor: remove deprecated SQL files and journal entries for left smasher 2025-10-24 23:42:14 -06:00
Mauricio Siu
0b1e79a8e1 Merge pull request #2821 from imran-vz/copy-2fa-backup-codes
feat: add a button to copy backup codes to clipboard
2025-10-24 23:39:49 -06:00
Mauricio Siu
66e0bcc4c6 Merge branch 'canary' into copy-2fa-backup-codes 2025-10-24 23:35:56 -06:00
Mauricio Siu
962d405436 Merge pull request #2468 from ShadowJonathan/fix-gitea-and-forgejo
Change gitea permissions to new instances
2025-10-24 23:34:08 -06:00
Mauricio Siu
6b547dbc32 Merge pull request #2868 from SimonLoir/fix-jwt-generator
fix(templates): add trim on payload in value processor
2025-10-24 23:27:38 -06:00
Mauricio Siu
ba7a325e8f test(helpers): add tests for JWT payload handling with newlines and whitespace 2025-10-24 23:25:55 -06:00
Mauricio Siu
24df8e79fa Merge pull request #2834 from Harikrishnan1367709/Delete-Icon-Overflows-Container-for-Long-Scheduled-Task-Commands-#2832
fix(schedules): Fix UI overflow for long scheduled task commands -#2832
2025-10-24 23:17:44 -06:00
Mauricio Siu
6c3f72858b Merge pull request #2886 from Harikrishnan1367709/S3-Destinations-Error-message-overflows-#2885
fix(ui): prevent error message overflow in S3 Destinations modal -#2885
2025-10-24 23:15:35 -06:00
hl9020
ba4626c7da feat: add copy to clipboard functionality for deployment and runtime logs 2025-10-24 16:38:29 +02:00
autofix-ci[bot]
166b58b70e [autofix.ci] apply automated fixes 2025-10-24 06:36:40 +00:00
HarikrishnanD
74e17b4de6 fix(ui): prevent error message overflow in S3 Destinations modal 2025-10-24 12:04:43 +05:30
Simon Loir
7bddc6f46b fix(templates): use trimStart and trimEnd instead of generic trim 2025-10-22 14:53:28 +02:00
autofix-ci[bot]
036eaa3c2d [autofix.ci] apply automated fixes 2025-10-21 19:34:37 +00:00
Simon Loir
be80148310 fix(templates): add trim on payload in value processor 2025-10-21 21:29:05 +02:00
autofix-ci[bot]
b662629075 [autofix.ci] apply automated fixes 2025-10-21 08:22:36 +00:00
HarikrishnanD
0077954c78 feat: add Ctrl+S/CMD+S shortcuts to .env file editing 2025-10-21 13:50:56 +05:30
Mohammed Imran
622bb3ff4e chore: simplify 2FA component rendering in profile form 2025-10-17 23:17:59 +05:30
Mohammed Imran
7817a3c2fb chore: rename disable-2fa to configure-2fa 2025-10-17 23:10:09 +05:30
Mohammed Imran
71152b664b feature: enhance 2FA management UI and logic in profile settings
- Replaced AlertDialog with Dialog for managing 2FA settings, improving user experience.
- Introduced multi-step dialog flow for verifying identity, managing actions, and displaying backup codes.
2025-10-17 23:08:54 +05:30
Mohammed Imran
63f3bb8cf2 refactor: Add a basic template to backup codes copy and download 2025-10-17 19:47:03 +05:30
autofix-ci[bot]
8510bcbd40 [autofix.ci] apply automated fixes 2025-10-17 06:15:48 +00:00
HarikrishnanD
7fe59ba51b fix(volume-backups): track loading state per backup 2025-10-17 11:42:17 +05:30
Mohammed Imran
d858acbaaa chore: add comment to ignore lint warning for img element in 2FA QR code 2025-10-16 21:16:41 +05:30
autofix-ci[bot]
9925470663 [autofix.ci] apply automated fixes 2025-10-16 10:58:02 +00:00
HarikrishnanD
342b1d676e fix(schedules): prevent action buttons overflow for long commands 2025-10-16 16:25:32 +05:30
autofix-ci[bot]
6d52ab13a6 [autofix.ci] apply automated fixes 2025-10-16 06:50:39 +00:00
HarikrishnanD
819bb6ca14 fix(schedules): track loading state per scheduler 2025-10-16 12:17:45 +05:30
Mohammed Imran
8338b27ab8 feat: add functionality to download
refactor the copy feature to use `copy-to-clipboard`.
2025-10-16 10:30:01 +05:30
Mohammed Imran
901013ccd1 fix: ensure button type is correctly set when not explicitly defined 2025-10-16 10:29:10 +05:30
Mauricio Siu
ceb4cc453e feat: add LambdaTest sponsorship to README 2025-10-15 22:09:25 -06:00
Mauricio Siu
2b19632cdc Merge pull request #2809 from rodsnts/fix-profile-pic-fit
fix: profile picture image fit
2025-10-15 22:01:11 -06:00
Mauricio Siu
a99ac01eba Merge pull request #2812 from dennisimoo/fix-typos
fix: correct typos
2025-10-15 22:00:11 -06:00
Mauricio Siu
8b82832204 Merge pull request #2824 from Jupi2051/fix-tooltips-submitting-form
fix: resources tooltips trigger form submission
2025-10-15 21:58:10 -06:00
autofix-ci[bot]
5fd39843f7 [autofix.ci] apply automated fixes 2025-10-16 03:56:14 +00:00
Mauricio Siu
557923c011 Merge pull request #2796 from vytenisstaugaitis/canary
fix(notifications): prevent blank email field on dialog reopen
2025-10-15 21:53:01 -06:00
Mauricio Siu
3aaef9cc3e Merge pull request #2807 from SimonLoir/fix-middleware-emptied-on-aplication-delete
fix: load remote middleware on app delete if a serverId is provided
2025-10-15 21:44:39 -06:00
Mauricio Siu
7a777550a0 Merge pull request #2799 from ajnart/patch-1
feat: Bump default MongoDB docker image version to 7
2025-10-14 22:41:30 -06:00
Jupi
b3cec533f9 fix: resources tooltips triggers form submission 2025-10-15 04:50:53 +03:00
Mohammed Imran
78a9fe9dc5 feat: add a button to copy backup codes to clipboard 2025-10-14 23:31:41 +05:30
autofix-ci[bot]
68be6f451f [autofix.ci] apply automated fixes 2025-10-14 07:42:17 +00:00
HarikrishnanD
b6e6705de8 feat(ui): move environment variables icon outside dropdown - Relocate the environment variables icon from the dropdown menu to a visible position outside. - Update the UI to ensure the icon is accessible and intuitive for users -#2755 2025-10-14 13:09:34 +05:30
randomperson12344
d0fd8e7c72 fix: correct typos 2025-10-12 20:59:52 -07:00
CorentinMre
b200ed6a73 fix: update CPU usage calculation to use a one-second interval 2025-10-12 16:36:19 +02:00
CorentinMre
8537b6fbbf fix: use actual memory usage excluding cache/buffers in monitoring
- Replace v.Used with (Total - Available) calculation
- Provides accurate memory usage for monitoring alerts
- Prevents false alerts caused by Linux cache/buffers
- Aligns with 'free -h' available memory metric
2025-10-12 11:59:31 +02:00
Rodrigo Santos
883e9f0fd1 fix: profile picture fit 2025-10-11 19:20:46 +01:00
autofix-ci[bot]
7988de64c8 [autofix.ci] apply automated fixes 2025-10-11 14:32:31 +00:00
Simon Loir
fd5fa32964 fix: load remote middleware on app delete if a serverId is provided 2025-10-11 16:27:11 +02:00
Thomas Camlong
ca6a93fdf6 feat: Bump default MongoDB docker image version to 7
The previous version of this file used `mongo:6` which reached EOL on July 1st 2025

More at : https://www.mongodb.com/legal/support-policy/lifecycles
2025-10-09 16:09:13 +02:00
vytenisstaugaitis
0c37d7b3ee fix(notifications): prevent blank email field on dialog reopen 2025-10-09 11:27:07 +03:00
autofix-ci[bot]
8de5001471 [autofix.ci] apply automated fixes 2025-10-09 08:18:17 +00:00
HarikrishnanD
9b81d15b0c feat(ai): send Gemini API key as query param when listing models packages/server/src/utils/ai/select-ai-provider.ts: add "gemini" case using createOpenAICompatible with queryParams: { key: config.apiKey } and empty headers. apps/dokploy/server/api/routers/ai.ts: update "gemini" models fetch to call ${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)} with empty headers. 2025-10-09 13:45:47 +05:30
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
Jorge González
2cc9855ed2 fix(ci): Add missing buildSecrets declarations on tests 2025-10-01 18:41:00 -06:00
Jorge González
571e97f247 feat(docker): Build-time Secrets 2025-10-01 18:24:03 -06: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
Kirill
c4519256cf Pass DOKPLOY_DEPLOY_URL as build time argument so it can be used during build 2025-10-01 23:05:54 +03:00
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
autofix-ci[bot]
cd06b55a0c [autofix.ci] apply automated fixes 2025-09-23 16:46:21 +00:00
ischanx
b4a3cbdff4 feat(notifications): add lark webhook 2025-09-24 00:38:44 +08: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
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
Rob Graeber
1a940580ae Fix swarm settings config placeholders 2025-09-09 18:03:02 -06: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
Lucas Manchine
da9df3e239 testing changes 2025-09-05 11:49:32 -03:00
Jonathan de Jong
e645b31b32 change gitea permissions to new instances (#1832) 2025-08-26 07:53:20 +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
Lucas Manchine
64290fcbf6 fix linter issues 2025-07-29 09:33:19 -03: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
104 changed files with 36283 additions and 472 deletions

View File

@@ -77,6 +77,10 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
</a>
</div>
<!-- Premium Supporters 🥇 -->

View File

@@ -48,6 +48,7 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -73,6 +74,7 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",
@@ -133,6 +135,7 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
};
describe("unzipDrop using real zip files", () => {

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

@@ -228,5 +228,58 @@ describe("helpers functions", () => {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
);
});
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithNewlines = `{
"role": "anon",
"iss": "supabase",
"exp": ${expiry}
}
`;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithNewlines,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("anon");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
it("should handle JWT payload with leading and trailing whitespace", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithWhitespace,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("service_role");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
});
});

View File

@@ -25,8 +25,10 @@ const baseApp: ApplicationNested = {
registryUrl: "",
watchPaths: [],
buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
@@ -111,6 +113,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

@@ -150,7 +150,10 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -182,7 +185,10 @@ export const ShowResources = ({ id, type }: Props) => {
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -215,7 +221,10 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -249,7 +258,10 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>

View File

@@ -318,7 +318,7 @@ export const AddVolumes = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -327,7 +327,7 @@ export const AddVolumes = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
className="h-96 font-mono "
{...field}
/>
</FormControl>

View File

@@ -1,6 +1,8 @@
import { Loader2 } from "lucide-react";
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -29,9 +31,10 @@ export const ShowDeployment = ({
const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const wsRef = useRef<WebSocket | null>(null);
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -106,6 +109,20 @@ export const ShowDeployment = ({
}
}, [filteredLogs, autoScroll]);
const handleCopy = () => {
const logContent = filteredLogs
.map(({ timestamp, message }: LogLine) =>
`${timestamp?.toISOString() || ""} ${message}`.trim(),
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const optionalErrors = parseLogs(errorMessage || "");
return (
@@ -128,13 +145,27 @@ export const ShowDeployment = ({
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span>
<span className="flex items-center gap-2">
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -108,6 +108,21 @@ export const ShowEnvironment = ({ id, type }: Props) => {
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">

View File

@@ -12,6 +12,7 @@ import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -37,6 +38,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
defaultValues: {
env: "",
buildArgs: "",
buildSecrets: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -44,15 +46,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "");
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
});
}
}, [data, form]);
@@ -61,6 +66,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
mutateAsync({
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
applicationId,
})
.then(async () => {
@@ -76,9 +82,25 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
form.reset({
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return (
<Card className="bg-background px-6 pb-6">
<Form {...form}>
@@ -104,13 +126,36 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
title="Build-time Arguments"
description={
<span>
Available only at build-time. See documentation&nbsp;
Arguments are available only at build-time. See
documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/guide/build-args/"
href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information and
are only available at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

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

@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

View File

@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

View File

@@ -46,6 +46,7 @@ const schema = z
.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
@@ -109,6 +110,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
form.reset({
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
@@ -127,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
updateApplication({
previewEnv: formData.env,
previewBuildArgs: formData.buildArgs,
previewBuildSecrets: formData.buildSecrets,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
previewLabels: formData.previewLabels,
@@ -467,13 +470,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
title="Build-time Arguments"
description={
<span>
Available only at build-time. See documentation&nbsp;
Arguments are available only at build-time. See
documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/guide/build-args/"
href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information
and are only available at build-time. See
documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -6,6 +6,7 @@ import {
Terminal,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -33,6 +34,9 @@ interface Props {
}
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
new Set(),
);
const {
data: schedules,
isLoading: isLoadingSchedules,
@@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
const { mutateAsync: runManually, isLoading } =
api.schedule.runManually.useMutation();
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
await refetchSchedules();
} catch {
toast.error("Error running schedule");
} finally {
setRunningSchedules((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
return newSet;
});
}
};
return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
@@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
@@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</CardHeader>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading scheduled tasks...
@@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return (
<div
key={schedule.scheduleId}
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50"
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
>
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 w-full sm:w-auto">
<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="space-y-1.5 w-full sm:w-auto">
<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}
@@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
)}
</div>
{schedule.command && (
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
<div className="flex items-start gap-2 max-w-full">
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
{schedule.command}
</code>
</div>
)}
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1.5">
<ShowDeploymentsModal
id={schedule.scheduleId}
@@ -149,10 +164,9 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
<ClipboardList className="size-4 transition-colors" />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
type="button"
variant="ghost"
size="icon"
isLoading={isLoading}
onClick={async () => {
toast.success("Schedule run successfully");
await runManually({
scheduleId: schedule.scheduleId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchSchedules();
})
.catch(() => {
toast.error("Error running schedule");
});
}}
disabled={runningSchedules.has(schedule.scheduleId)}
onClick={() =>
handleRunManually(schedule.scheduleId)
}
>
<Play className="size-4 transition-colors" />
{runningSchedules.has(schedule.scheduleId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Schedule</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleSchedules
scheduleId={schedule.scheduleId}
id={id}
scheduleType={scheduleType}
/>
<DialogAction
title="Delete Schedule"
description="Are you sure you want to delete this schedule?"
@@ -214,8 +217,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isDeleting}
className="group hover:bg-red-500/10"
disabled={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>

View File

@@ -5,6 +5,7 @@ import {
Play,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({
type = "application",
serverId,
}: Props) => {
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
const {
data: volumeBackups,
isLoading: isLoadingVolumeBackups,
@@ -51,19 +53,33 @@ export const ShowVolumeBackups = ({
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually, isLoading } =
const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation();
const handleRunManually = async (volumeBackupId: string) => {
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
try {
await runManually({ volumeBackupId });
toast.success("Volume backup run successfully");
await refetchVolumeBackups();
} catch {
toast.error("Error running volume backup");
} finally {
setRunningBackups((prev) => {
const newSet = new Set(prev);
newSet.delete(volumeBackupId);
return newSet;
});
}
};
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 flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
@@ -73,12 +89,10 @@ export const ShowVolumeBackups = ({
intervals.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
@@ -93,7 +107,7 @@ export const ShowVolumeBackups = ({
</CardHeader>
<CardContent className="px-0">
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading volume backups...
@@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({
return (
<div
key={volumeBackup.volumeBackupId}
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
>
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 w-full sm:w-auto">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<DatabaseBackup className="size-4 text-primary/70" />
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 w-full sm:w-auto">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium leading-none">
{volumeBackup.name}
@@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({
</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
<ShowDeploymentsModal
id={volumeBackup.volumeBackupId}
type="volumeBackup"
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
<ClipboardList className="size-4 transition-colors" />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({
type="button"
variant="ghost"
size="icon"
isLoading={isLoading}
onClick={async () => {
toast.success("Volume backup run successfully");
await runManually({
volumeBackupId: volumeBackup.volumeBackupId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchVolumeBackups();
})
.catch(() => {
toast.error("Error running volume backup");
});
}}
disabled={runningBackups.has(
volumeBackup.volumeBackupId,
)}
onClick={() =>
handleRunManually(volumeBackup.volumeBackupId)
}
>
<Play className="size-4 transition-colors" />
{runningBackups.has(volumeBackup.volumeBackupId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleVolumeBackups
volumeBackupId={volumeBackup.volumeBackupId}
id={id}
volumeBackupType={type}
/>
<DialogAction
title="Delete Volume Backup"
description="Are you sure you want to delete this volume backup?"
@@ -218,7 +221,7 @@ export const ShowVolumeBackups = ({
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
className="group hover:bg-red-500/10"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
@@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({
})}
</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">
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground">
No volume backups

View File

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

View File

@@ -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

@@ -74,6 +74,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
await mutateAsync({
composeId,
composeFile: data.composeFile,
composePath: "./docker-compose.yml",
sourceType: "raw",
})
.then(async () => {

View File

@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

View File

@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
triggerType: data.triggerType,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

View File

@@ -160,7 +160,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {

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

@@ -1,4 +1,12 @@
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
import copy from "copy-to-clipboard";
import {
Check,
Copy,
Download as DownloadIcon,
Loader2,
Pause,
Play,
} from "lucide-react";
import React, { useEffect, useRef } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
@@ -67,6 +75,7 @@ export const DockerLogsId: React.FC<Props> = ({
const isPausedRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -237,6 +246,29 @@ export const DockerLogsId: React.FC<Props> = ({
URL.revokeObjectURL(url);
};
const handleCopy = async () => {
const logContent = filteredLogs
.map(
({
timestamp,
message,
}: {
timestamp: Date | null;
message: string;
}) =>
showTimestamp
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
: message,
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleFilter = (logs: LogLine[]) => {
return logs.filter((log) => {
const logType = getLogType(log.message).type;
@@ -320,6 +352,21 @@ export const DockerLogsId: React.FC<Props> = ({
)}
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
>
{copied ? (
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
Copy
</Button>
<Button
variant="outline"
size="sm"

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

@@ -281,6 +281,7 @@ export const ImpersonationBar = () => {
<div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10">
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
/>

View File

@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
type DbType = typeof mySchema._type.type;
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:6",
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:15",

View File

@@ -63,13 +63,20 @@ export const AdvancedEnvironmentSelector = ({
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// API mutations
const { data: environment } = api.environment.one.useQuery(
{ environmentId: currentEnvironmentId || "" },
{
enabled: !!currentEnvironmentId,
},
);
// 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 &&
@@ -241,7 +248,7 @@ export const AdvancedEnvironmentSelector = ({
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
<EnvironmentVariables environmentId={environment.environmentId}>
{/* <EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
@@ -252,7 +259,7 @@ export const AdvancedEnvironmentSelector = ({
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables>
</EnvironmentVariables> */}
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button
@@ -267,17 +274,19 @@ export const AdvancedEnvironmentSelector = ({
<PencilIcon className="h-3 w-3" />
</Button>
<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>
{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>
@@ -285,13 +294,15 @@ export const AdvancedEnvironmentSelector = ({
})}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
{canCreateEnvironments && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
.finally(() => {});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, isOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>

View File

@@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.finally(() => {});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, isOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>

View File

@@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
</DialogDescription>
</DialogHeader>
{(isError || isErrorConnection) && (
<AlertBlock type="error" className="break-words">
<AlertBlock type="error" className="w-full">
{connectionError?.message || error?.message}
</AlertBlock>
)}

View File

@@ -1,17 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
AlertTriangle,
Mail,
MessageCircleMore,
PenBoxIcon,
PlusIcon,
} from "lucide-react";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -110,6 +107,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -125,16 +128,20 @@ export const notificationsMap = {
icon: <DiscordIcon />,
label: "Discord",
},
lark: {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
gotify: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
icon: <GotifyIcon />,
label: "Gotify",
},
ntfy: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
icon: <NtfyIcon />,
label: "ntfy",
},
};
@@ -170,6 +177,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -188,6 +197,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -206,10 +218,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if (type === "email") {
if (type === "email" && fields.length === 0) {
append("");
}
}, [type, append]);
}, [type, append, fields.length]);
useEffect(() => {
if (notification) {
@@ -297,6 +309,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverUrl: notification.ntfy?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "lark") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
} else {
@@ -311,6 +336,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
email: emailMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -414,6 +440,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
} else if (data.type === "lark") {
promise = larkMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
}
if (promise) {
@@ -502,7 +541,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
<Label
htmlFor={key}
className="flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
className="h-24 flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
{value.icon}
{value.label}
@@ -1000,6 +1039,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "lark" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1150,7 +1210,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingDiscord ||
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy
isLoadingNtfy ||
isLoadingLark
}
variant="secondary"
onClick={async () => {
@@ -1194,6 +1255,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
});
} else if (type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
});
}
toast.success("Connection Success");
} catch {

View File

@@ -1,7 +1,10 @@
import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -33,7 +36,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
Telegram, Email, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -85,12 +88,17 @@ 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 ">
<MessageCircleMore className="size-6 text-muted-foreground" />
<NtfyIcon className="size-6" />
</div>
)}
{notification.notificationType === "lark" && (
<div className="flex items-center justify-center rounded-lg">
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}

View File

@@ -0,0 +1,429 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import {
CopyIcon,
DownloadIcon,
KeyRound,
RefreshCw,
ShieldOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import {
BACKUP_CODES_PLACEHOLDER,
backupCodeTemplate,
DATE_PLACEHOLDER,
USERNAME_PLACEHOLDER,
} from "./enable-2fa";
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
type PasswordForm = z.infer<typeof PasswordSchema>;
type Step = "password" | "actions" | "backup-codes";
export const Configure2FA = () => {
const utils = api.useUtils();
const { data: currentUser } = api.user.get.useQuery();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<Step>("password");
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const form = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setPassword("");
setBackupCodes([]);
form.reset();
}
}, [isDialogOpen, form]);
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsRegenerating(true);
try {
// Verify password by attempting to generate backup codes
// This validates the password and checks if 2FA is enabled
const result = await authClient.twoFactor.generateBackupCodes({
password: formData.password,
});
if (result.error) {
form.setError("password", { message: result.error.message });
toast.error(result.error.message);
return;
}
// If we get here, password is correct
setPassword(formData.password);
setStep("actions");
} catch (error) {
form.setError("password", {
message: error instanceof Error ? error.message : "Incorrect password",
});
toast.error("Incorrect password");
} finally {
setIsRegenerating(false);
}
};
const handleRegenerateBackupCodes = async () => {
setIsRegenerating(true);
try {
const result = await authClient.twoFactor.generateBackupCodes({
password,
});
if (result.error) {
toast.error(result.error.message);
return;
}
if (result.data?.backupCodes) {
setBackupCodes(result.data.backupCodes);
setStep("backup-codes");
toast.success("Backup codes regenerated successfully");
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to regenerate backup codes",
);
} finally {
setIsRegenerating(false);
}
};
const handleDisable2FA = async () => {
setIsDisabling(true);
try {
const result = await authClient.twoFactor.disable({
password,
});
if (result.error) {
toast.error(result.error.message);
return;
}
toast.success("2FA disabled successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
setShowDisableConfirm(false);
} catch (error) {
toast.error("Failed to disable 2FA. Please try again.");
} finally {
setIsDisabling(false);
}
};
const handleCloseDialog = () => {
if (step === "backup-codes") {
setStep("actions");
} else {
setIsDialogOpen(false);
}
};
const handleDownloadBackupCodes = () => {
if (!backupCodes || backupCodes.length === 0) {
toast.error("No backup codes to download.");
return;
}
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
const blob = new Blob([backupCodesText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleCopyBackupCodes = () => {
const date = new Date();
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
copy(backupCodesText);
toast.success("Backup codes copied to clipboard");
};
return (
<>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<KeyRound className="size-4 text-muted-foreground" />
Manage 2FA
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{step === "password" && "Verify Your Identity"}
{step === "actions" && "2FA Configuration"}
{step === "backup-codes" && "New Backup Codes"}
</DialogTitle>
<DialogDescription>
{step === "password" &&
"Enter your password to manage your 2FA settings"}
{step === "actions" &&
"Choose an action to manage your two-factor authentication"}
{step === "backup-codes" &&
"Save these backup codes in a secure place"}
</DialogDescription>
</DialogHeader>
{step === "password" && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handlePasswordSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to continue
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isRegenerating}>
Continue
</Button>
</div>
</form>
</Form>
)}
{step === "actions" && (
<div className="space-y-4">
<div className="grid gap-3">
<div className="flex flex-col gap-2 p-4 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center gap-2">
<RefreshCw className="size-4" />
Regenerate Backup Codes
</h4>
<p className="text-sm text-muted-foreground mt-1">
Generate new backup codes to replace your existing ones.
This will invalidate all previous backup codes.
</p>
</div>
</div>
<Button
onClick={handleRegenerateBackupCodes}
variant="outline"
className="w-full mt-2"
isLoading={isRegenerating}
>
<RefreshCw className="size-4 mr-2" />
Regenerate Backup Codes
</Button>
</div>
<div className="flex flex-col gap-2 p-4 border border-destructive/50 rounded-lg hover:bg-destructive/5 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center gap-2 text-destructive">
<ShieldOff className="size-4" />
Disable 2FA
</h4>
<p className="text-sm text-muted-foreground mt-1">
Completely disable two-factor authentication for your
account. This will make your account less secure.
</p>
</div>
</div>
<Button
onClick={() => setShowDisableConfirm(true)}
variant="destructive"
className="w-full mt-2"
>
<ShieldOff className="size-4 mr-2" />
Disable 2FA
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Close
</Button>
</div>
</div>
)}
{step === "backup-codes" && (
<div className="space-y-4">
<div className="w-full space-y-3 border rounded-lg p-4 bg-muted/50">
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code
key={index}
className="bg-background p-2 rounded text-sm font-mono text-center"
>
{code}
</code>
))}
</div>
<p className="text-sm text-muted-foreground">
Save these backup codes in a secure place. You can use them to
access your account if you lose access to your authenticator
device. Each code can only be used once.
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownloadBackupCodes}
className="flex-1"
>
<DownloadIcon className="size-4 mr-2" />
Download
</Button>
<Button
variant="outline"
onClick={handleCopyBackupCodes}
className="flex-1"
>
<CopyIcon className="size-4 mr-2" />
Copy
</Button>
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={handleCloseDialog}>
Back to Actions
</Button>
<Button onClick={() => setIsDialogOpen(false)}>Done</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<AlertDialog
open={showDisableConfirm}
onOpenChange={setShowDisableConfirm}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently disable Two-Factor Authentication for your
account. Your account will be less secure without 2FA enabled.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisable2FA}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDisabling}
>
{isDisabling ? "Disabling..." : "Disable 2FA"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -1,135 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
type PasswordForm = z.infer<typeof PasswordSchema>;
export const Disable2FA = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const handleSubmit = async (formData: PasswordForm) => {
setIsLoading(true);
try {
const result = await authClient.twoFactor.disable({
password: formData.password,
});
if (result.error) {
form.setError("password", {
message: result.error.message,
});
toast.error(result.error.message);
return;
}
toast.success("2FA disabled successfully");
utils.user.get.invalidate();
setIsOpen(false);
} catch {
form.setError("password", {
message: "Connection error. Please try again.",
});
toast.error("Connection error. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">Disable 2FA</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently disable
Two-Factor Authentication for your account.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to disable 2FA
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset();
setIsOpen(false);
}}
>
Cancel
</Button>
<Button type="submit" variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
</div>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Fingerprint, QrCode } from "lucide-react";
import copy from "copy-to-clipboard";
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
import QRCode from "qrcode";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -29,6 +30,12 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
@@ -54,6 +61,26 @@ type TwoFactorSetupData = {
type PasswordForm = z.infer<typeof PasswordSchema>;
type PinForm = z.infer<typeof PinSchema>;
export const USERNAME_PLACEHOLDER = "%username%";
export const DATE_PLACEHOLDER = "%date%";
export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%";
export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES
Points to note
--------------
# Each code can be used only once.
# Do not share these codes with anyone.
Generated codes
---------------
Username: ${USERNAME_PLACEHOLDER}
Generated on: ${DATE_PLACEHOLDER}
${BACKUP_CODES_PLACEHOLDER}
`;
export const Enable2FA = () => {
const utils = api.useUtils();
const [data, setData] = useState<TwoFactorSetupData | null>(null);
@@ -62,6 +89,7 @@ export const Enable2FA = () => {
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const { data: currentUser } = api.user.get.useQuery();
const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -178,6 +206,54 @@ export const Enable2FA = () => {
}
};
const handleDownloadBackupCodes = () => {
if (!backupCodes || backupCodes.length === 0) {
toast.error("No backup codes to download.");
return;
}
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
const blob = new Blob([backupCodesText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleCopyBackupCodes = () => {
const date = new Date();
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
copy(backupCodesText);
toast.success("Backup codes copied to clipboard");
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@@ -264,6 +340,7 @@ export const Enable2FA = () => {
<span className="text-sm font-medium">
Scan this QR code with your authenticator app
</span>
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
<img
src={data.qrCodeUrl}
alt="2FA QR Code"
@@ -281,7 +358,46 @@ export const Enable2FA = () => {
{backupCodes && backupCodes.length > 0 && (
<div className="w-full space-y-3 border rounded-lg p-4">
<h4 className="font-medium">Backup Codes</h4>
<div className="flex items-center justify-between">
<h4 className="font-medium">Backup Codes</h4>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyBackupCodes}
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleDownloadBackupCodes}
>
<DownloadIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code

View File

@@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Disable2FA } from "./disable-2fa";
import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
@@ -62,7 +62,6 @@ const randomImages = [
];
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -120,28 +119,27 @@ export const ProfileForm = () => {
}, [form, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
})
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
});
})
.catch(() => {
toast.error("Error updating the profile");
try {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
});
await refetch();
toast.success("Profile Updated");
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
});
} catch (error) {
toast.error("Error updating the profile");
}
};
return (
@@ -158,7 +156,8 @@ export const ProfileForm = () => {
{t("settings.profile.description")}
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -257,8 +256,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">
@@ -279,6 +286,72 @@ 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:") ? (
// biome-ignore lint/performance/noImgElement: this is an justified use of img element
<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">
@@ -289,6 +362,7 @@ export const ProfileForm = () => {
/>
</FormControl>
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
<img
key={image}
src={image}

View File

@@ -1,6 +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";
@@ -161,11 +161,13 @@ const addPermissions = z.object({
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>;
@@ -175,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(
@@ -192,13 +195,25 @@ 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 || [],
@@ -207,14 +222,16 @@ export const AddUserPermissions = ({ userId }: Props) => {
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({
@@ -223,6 +240,7 @@ 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 || [],
@@ -231,17 +249,19 @@ export const AddUserPermissions = ({ userId }: Props) => {
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"
@@ -343,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"

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

@@ -75,6 +75,21 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && !canEdit) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, canEdit]);
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>

View File

@@ -88,3 +88,146 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-icon="LarkLogoColorful"
className={cn("size-9", className)}
>
<path
d="m12.924 12.803.056-.054c.038-.034.076-.072.11-.11l.077-.076.23-.227 1.334-1.319.335-.331c.063-.063.13-.123.195-.183a7.777 7.777 0 0 1 1.823-1.24 7.607 7.607 0 0 1 1.014-.4 13.177 13.177 0 0 0-2.5-5.013 1.203 1.203 0 0 0-.94-.448h-9.65c-.173 0-.246.224-.107.325a28.23 28.23 0 0 1 8 9.098c.007-.006.016-.013.023-.022Z"
fill="#00D6B9"
/>
<path
d="M9.097 21.299a13.258 13.258 0 0 0 11.82-7.247 5.576 5.576 0 0 1-.731 1.076 5.315 5.315 0 0 1-.745.7 5.117 5.117 0 0 1-.615.404 4.626 4.626 0 0 1-.726.331 5.312 5.312 0 0 1-1.883.312 5.892 5.892 0 0 1-.524-.031 6.509 6.509 0 0 1-.729-.126c-.06-.016-.12-.029-.18-.044-.166-.044-.33-.092-.494-.14-.082-.024-.164-.046-.246-.072-.123-.038-.247-.072-.366-.11l-.3-.095-.284-.094-.192-.067c-.08-.025-.155-.053-.234-.082a3.49 3.49 0 0 1-.167-.06c-.11-.04-.221-.079-.328-.12-.063-.025-.126-.047-.19-.072l-.252-.098c-.088-.035-.18-.07-.268-.107l-.174-.07c-.072-.028-.141-.06-.214-.088l-.164-.07c-.057-.024-.114-.05-.17-.075l-.149-.066-.135-.06-.14-.063a90.183 90.183 0 0 1-.141-.066 4.808 4.808 0 0 0-.18-.083c-.063-.028-.123-.06-.186-.088a5.697 5.697 0 0 1-.199-.098 27.762 27.762 0 0 1-8.067-5.969.18.18 0 0 0-.312.123l.006 9.21c0 .4.199.779.533 1a13.177 13.177 0 0 0 7.326 2.205Z"
fill="#3370FF"
/>
<path
d="M23.732 9.295a7.55 7.55 0 0 0-3.35-.776 7.521 7.521 0 0 0-2.284.35c-.054.016-.107.035-.158.05a8.297 8.297 0 0 0-.855.35 7.14 7.14 0 0 0-.552.297 6.716 6.716 0 0 0-.533.347c-.123.089-.243.18-.363.275-.13.104-.252.211-.375.321-.067.06-.13.123-.196.184l-.334.328-1.338 1.321-.23.228-.076.075c-.038.038-.076.073-.11.11l-.057.054a1.914 1.914 0 0 1-.085.08c-.032.028-.063.06-.095.088a13.286 13.286 0 0 1-2.748 1.946c.06.028.12.057.18.082l.142.066c.044.022.091.041.139.063l.135.06.149.067.17.075.164.07c.073.031.142.06.215.088.056.025.116.047.173.07.088.034.177.072.268.107.085.031.168.066.253.098l.189.072c.11.041.218.082.328.12.057.019.11.041.167.06.08.028.155.053.234.082l.192.066.284.095.3.095c.123.037.243.075.366.11l.246.072c.164.048.331.095.495.14.06.015.12.03.18.043.114.029.227.05.34.07.13.022.26.04.389.057a5.815 5.815 0 0 0 .994.019 5.172 5.172 0 0 0 1.413-.3 5.405 5.405 0 0 0 .726-.334c.06-.035.122-.07.182-.108a7.96 7.96 0 0 0 .432-.297 5.362 5.362 0 0 0 .577-.517 5.285 5.285 0 0 0 .37-.429 5.797 5.797 0 0 0 .527-.827l.13-.258 1.166-2.325-.003.006a7.391 7.391 0 0 1 1.527-2.186Z"
fill="#133C9A"
/>
</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

@@ -44,6 +44,7 @@ export const UserNav = () => {
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.image || ""}
/>

View File

@@ -39,13 +39,19 @@ export function AlertBlock({
<div
{...props}
className={cn(
"flex items-center flex-row gap-4 rounded-lg p-2",
"flex items-start flex-row gap-4 rounded-lg p-2",
iconClassName,
className,
)}
>
{icon || <Icon className="text-current" />}
<span className="text-sm text-current">{children}</span>
<div className="flex-shrink-0 mt-0.5">
{icon || <Icon className="text-current" />}
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-current break-words overflow-wrap-anywhere whitespace-pre-wrap">
{children}
</span>
</div>
</div>
);
}

View File

@@ -55,6 +55,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref,
) => {
const Comp = asChild ? Slot : "button";
const type = props.type ?? undefined;
return (
<>
<Comp
@@ -65,6 +67,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref}
{...props}
disabled={isLoading || props.disabled}
type={type}
>
{isLoading && <Loader2 className="animate-spin" />}
<Slottable>{children}</Slottable>

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;

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "previewBuildSecrets" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildSecrets" text;

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -799,6 +799,41 @@
"when": 1758960816504,
"tag": "0113_complete_rafael_vega",
"breakpoints": true
},
{
"idx": 114,
"version": "7",
"when": 1759643172958,
"tag": "0114_dry_black_tom",
"breakpoints": true
},
{
"idx": 115,
"version": "7",
"when": 1759644540829,
"tag": "0115_serious_black_bird",
"breakpoints": true
},
{
"idx": 116,
"version": "7",
"when": 1759645163834,
"tag": "0116_amusing_firedrake",
"breakpoints": true
},
{
"idx": 117,
"version": "7",
"when": 1761370953274,
"tag": "0117_lumpy_nuke",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1761415824484,
"tag": "0118_loose_anita_blake",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.4",
"version": "v0.25.6",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -368,14 +368,14 @@ export const extractCommitedPaths = async (
.map((change: any) => change.new?.target?.hash)
.filter(Boolean);
const commitedPaths: string[] = [];
const username =
bitbucket?.bitbucketWorkspaceName || bitbucket?.bitbucketUsername || "";
for (const commit of commitHashes) {
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucket?.bitbucketUsername}/${repository}/diffstat/${commit}`;
const url = `https://api.bitbucket.org/2.0/repositories/${username}/${repository}/diffstat/${commit}`;
try {
const response = await fetch(url, {
headers: getBitbucketHeaders(bitbucket!),
});
const data = await response.json();
for (const value of data.values) {
if (value?.new?.path) commitedPaths.push(value.new.path);

View File

@@ -14,6 +14,7 @@ import {
PlusIcon,
Search,
ServerIcon,
SquareTerminal,
Trash2,
X,
} from "lucide-react";
@@ -33,6 +34,7 @@ import { AddDatabase } from "@/components/dashboard/project/add-database";
import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment";
import {
MariadbIcon,
@@ -46,6 +48,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
@@ -95,7 +98,6 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
export type Services = {
appName: string;
@@ -776,6 +778,11 @@ const EnvironmentPage = (
projectId={projectId}
currentEnvironmentId={environmentId}
/>
<EnvironmentVariables environmentId={environmentId}>
<Button variant="ghost" size="icon">
<SquareTerminal className="size-5 text-muted-foreground cursor-pointer" />
</Button>
</EnvironmentVariables>
</CardTitle>
<CardDescription>
{currentEnvironment.description || "No description provided"}

View File

@@ -101,7 +101,7 @@ const Register = ({ isCloud }: Props) => {
setIsError(true);
setError(error.message || "An error occurred");
} else {
toast.success("User registered successfuly", {
toast.success("User registered successfully", {
duration: 2000,
});
if (!isCloud) {

View File

@@ -62,6 +62,12 @@ export const aiRouter = createTRPCRouter({
case "ollama":
response = await fetch(`${input.apiUrl}/api/tags`, { headers });
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
{ headers: {} },
);
break;
default:
if (!input.apiKey)
throw new TRPCError({

View File

@@ -360,6 +360,7 @@ export const applicationRouter = createTRPCRouter({
await updateApplication(input.applicationId, {
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
});
return true;
}),
@@ -524,7 +525,7 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveGitProdiver: protectedProcedure
saveGitProvider: protectedProcedure
.input(apiSaveGitProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);

View File

@@ -1,6 +1,8 @@
import {
addNewEnvironment,
checkEnvironmentAccess,
checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
createEnvironment,
deleteEnvironment,
duplicateEnvironment,
@@ -54,9 +56,12 @@ export const environmentRouter = createTRPCRouter({
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
// Check if user has access to the project
// This would typically involve checking project ownership/membership
// For now, we'll use a basic organization check
// Check if user has permission to create environments
await checkEnvironmentCreationPermission(
ctx.user.id,
input.projectId,
ctx.session.activeOrganizationId,
);
if (input.name === "production") {
throw new TRPCError({
@@ -76,6 +81,9 @@ export const environmentRouter = createTRPCRouter({
}
return environment;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error creating the environment: ${error instanceof Error ? error.message : error}`,
@@ -187,14 +195,6 @@ export const environmentRouter = createTRPCRouter({
.input(apiRemoveEnvironment)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !==
@@ -206,27 +206,33 @@ export const environmentRouter = createTRPCRouter({
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Check environment deletion permission
await checkEnvironmentDeletionPermission(
ctx.user.id,
environment.projectId,
ctx.session.activeOrganizationId,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete this environment",
});
}
// Additional check for environment access for members
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const deletedEnvironment = await deleteEnvironment(input.environmentId);
return deletedEnvironment;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error deleting the environment: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),

View File

@@ -1,6 +1,7 @@
import {
createDiscordNotification,
createEmailNotification,
createLarkNotification,
createGotifyNotification,
createNtfyNotification,
createSlackNotification,
@@ -10,6 +11,7 @@ import {
removeNotificationById,
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendServerThresholdNotifications,
@@ -17,6 +19,7 @@ import {
sendTelegramNotification,
updateDiscordNotification,
updateEmailNotification,
updateLarkNotification,
updateGotifyNotification,
updateNtfyNotification,
updateSlackNotification,
@@ -35,6 +38,7 @@ import { db } from "@/server/db";
import {
apiCreateDiscord,
apiCreateEmail,
apiCreateLark,
apiCreateGotify,
apiCreateNtfy,
apiCreateSlack,
@@ -42,12 +46,14 @@ import {
apiFindOneNotification,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestLarkConnection,
apiTestGotifyConnection,
apiTestNtfyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateLark,
apiUpdateGotify,
apiUpdateNtfy,
apiUpdateSlack,
@@ -328,6 +334,7 @@ export const notificationRouter = createTRPCRouter({
email: true,
gotify: true,
ntfy: true,
lark: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -511,6 +518,63 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createLark: adminProcedure
.input(apiCreateLark)
.mutation(async ({ input, ctx }) => {
try {
return await createLarkNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateLark: adminProcedure
.input(apiUpdateLark)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateLarkNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testLarkConnection: adminProcedure
.input(apiTestLarkConnection)
.mutation(async ({ input }) => {
try {
await sendLarkNotification(input, {
msg_type: "text",
content: {
text: "Hi, From Dokploy 👋",
},
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),

View File

@@ -1,4 +1,5 @@
import {
addNewEnvironment,
addNewProject,
checkProjectAccess,
createApplication,
@@ -85,6 +86,12 @@ export const projectRouter = createTRPCRouter({
project.project.projectId,
ctx.session.activeOrganizationId,
);
await addNewEnvironment(
ctx.user.id,
project?.environment?.environmentId || "",
ctx.session.activeOrganizationId,
);
}
return project;

View File

@@ -22,7 +22,7 @@ export const getGiteaOAuthUrl = (
}
const redirectUri = `${baseUrl}/api/providers/gitea/callback`;
const scopes = "repo repo:status read:user read:org";
const scopes = "read:repository read:user read:organization";
return `${giteaUrl}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri,

View File

@@ -117,7 +117,7 @@ func getRealOS() string {
func GetServerMetrics() database.ServerMetric {
v, _ := mem.VirtualMemory()
c, _ := cpu.Percent(0, false)
c, _ := cpu.Percent(time.Second, false)
cpuInfo, _ := cpu.Info()
diskInfo, _ := disk.Usage("/")
netInfo, _ := net.IOCounters(false)
@@ -130,7 +130,8 @@ func GetServerMetrics() database.ServerMetric {
}
memTotalGB := float64(v.Total) / 1024 / 1024 / 1024
memUsedGB := float64(v.Used) / 1024 / 1024 / 1024
memAvailableGB := float64(v.Available) / 1024 / 1024 / 1024
memUsedGB := memTotalGB - memAvailableGB
memUsedPercent := (memUsedGB / memTotalGB) * 100
var networkIn, networkOut float64

View File

@@ -108,6 +108,12 @@ export const member = pgTable("member", {
canAccessToTraefikFiles: boolean("canAccessToTraefikFiles")
.notNull()
.default(false),
canDeleteEnvironments: boolean("canDeleteEnvironments")
.notNull()
.default(false),
canCreateEnvironments: boolean("canCreateEnvironments")
.notNull()
.default(false),
accessedProjects: text("accesedProjects")
.array()
.notNull()

View File

@@ -1,5 +1,6 @@
import { relations } from "drizzle-orm";
import {
bigint,
boolean,
integer,
json,
@@ -20,7 +21,6 @@ import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { ports } from "./port";
import { previewDeployments } from "./preview-deployments";
import { projects } from "./project";
import { redirects } from "./redirects";
import { registry } from "./registry";
import { security } from "./security";
@@ -80,6 +80,7 @@ export const applications = pgTable("application", {
previewEnv: text("previewEnv"),
watchPaths: text("watchPaths").array(),
previewBuildArgs: text("previewBuildArgs"),
previewBuildSecrets: text("previewBuildSecrets"),
previewLabels: text("previewLabels").array(),
previewWildcard: text("previewWildcard"),
previewPort: integer("previewPort").default(3000),
@@ -99,6 +100,7 @@ export const applications = pgTable("application", {
).default(true),
rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"),
buildSecrets: text("buildSecrets"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),
@@ -164,6 +166,7 @@ export const applications = pgTable("application", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
//
replicas: integer("replicas").default(1).notNull(),
applicationStatus: applicationStatus("applicationStatus")
@@ -252,6 +255,7 @@ const createSchema = createInsertSchema(applications, {
autoDeploy: z.boolean(),
env: z.string().optional(),
buildArgs: z.string().optional(),
buildSecrets: z.string().optional(),
name: z.string().min(1),
description: z.string().optional(),
memoryReservation: z.string().optional(),
@@ -303,6 +307,7 @@ const createSchema = createInsertSchema(applications, {
previewPort: z.number().optional(),
previewEnv: z.string().optional(),
previewBuildArgs: z.string().optional(),
previewBuildSecrets: z.string().optional(),
previewWildcard: z.string().optional(),
previewLimit: z.number().optional(),
previewHttps: z.boolean().optional(),
@@ -312,6 +317,7 @@ const createSchema = createInsertSchema(applications, {
watchPaths: z.array(z.string()).optional(),
previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreateApplication = createSchema.pick({
@@ -456,6 +462,7 @@ export const apiSaveEnvironmentVariables = createSchema
applicationId: true,
env: true,
buildArgs: true,
buildSecrets: true,
})
.required();

View File

@@ -12,7 +12,6 @@ import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { projects } from "./project";
import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -62,6 +62,7 @@ export const mariadb = pgTable("mariadb", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -128,6 +129,7 @@ const createSchema = createInsertSchema(mariadb, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreateMariaDB = createSchema

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -58,6 +65,7 @@ export const mongo = pgTable("mongo", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -118,6 +126,7 @@ const createSchema = createInsertSchema(mongo, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreateMongo = createSchema

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -60,6 +60,7 @@ export const mysql = pgTable("mysql", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -125,6 +126,7 @@ const createSchema = createInsertSchema(mysql, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreateMySql = createSchema

View File

@@ -12,6 +12,7 @@ export const notificationType = pgEnum("notificationType", [
"email",
"gotify",
"ntfy",
"lark",
]);
export const notifications = pgTable("notification", {
@@ -48,6 +49,9 @@ export const notifications = pgTable("notification", {
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
larkId: text("larkId").references(() => lark.larkId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -116,6 +120,14 @@ export const ntfy = pgTable("ntfy", {
priority: integer("priority").notNull().default(3),
});
export const lark = pgTable("lark", {
larkId: text("larkId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -141,6 +153,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
lark: one(lark, {
fields: [notifications.larkId],
references: [lark.larkId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -339,6 +355,31 @@ export const apiFindOneNotification = notificationsSchema
})
.required();
export const apiCreateLark = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateLark = apiCreateLark.partial().extend({
notificationId: z.string().min(1),
larkId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiSendTest = notificationsSchema
.extend({
botToken: z.string(),

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -60,6 +60,7 @@ export const postgres = pgTable("postgres", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -118,6 +119,7 @@ const createSchema = createInsertSchema(postgres, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreatePostgres = createSchema

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -60,6 +60,7 @@ export const redis = pgTable("redis", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
replicas: integer("replicas").default(1).notNull(),
environmentId: text("environmentId")
@@ -108,6 +109,7 @@ const createSchema = createInsertSchema(redis, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
export const apiCreateRedis = createSchema

View File

@@ -186,6 +186,8 @@ export const apiAssignPermissions = createSchema
canAccessToAPI: z.boolean().optional(),
canAccessToSSHKeys: z.boolean().optional(),
canAccessToGitProviders: z.boolean().optional(),
canDeleteEnvironments: z.boolean().optional(),
canCreateEnvironments: z.boolean().optional(),
})
.required();

View File

@@ -68,7 +68,6 @@ export * from "./utils/backups/postgres";
export * from "./utils/backups/utils";
export * from "./utils/backups/web-server";
export * from "./utils/builders/compose";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/builders/docker-file";
export * from "./utils/builders/drop";
export * from "./utils/builders/heroku";
@@ -77,7 +76,6 @@ export * from "./utils/builders/nixpacks";
export * from "./utils/builders/paketo";
export * from "./utils/builders/static";
export * from "./utils/builders/utils";
export * from "./utils/cluster/upload";
export * from "./utils/databases/rebuild";
export * from "./utils/docker/collision";
@@ -113,6 +111,8 @@ export * from "./utils/providers/raw";
export * from "./utils/schedules/index";
export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types";

View File

@@ -10,6 +10,7 @@ import { db } from "../db";
import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import { updateUser } from "../services/user";
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
@@ -115,7 +116,7 @@ const { handler, api } = betterAuth({
}
}
},
after: async (user) => {
after: async (user, context) => {
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -126,6 +127,27 @@ const { handler, api } = betterAuth({
});
}
if (IS_CLOUD) {
try {
const hutk = getHubSpotUTK(
context?.request?.headers?.get("cookie") || undefined,
);
const hubspotSuccess = await submitToHubSpot(
{
email: user.email,
firstName: user.name,
lastName: user.name,
},
hutk,
);
if (!hubspotSuccess) {
console.error("Failed to submit to HubSpot");
}
} catch (error) {
console.error("Error submitting to HubSpot", error);
}
}
if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => {
const organization = await tx

View File

@@ -472,7 +472,8 @@ export const deployPreviewApplication = async ({
});
application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs;
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
if (application.sourceType === "github") {
await cloneGithubRepository({
@@ -579,7 +580,8 @@ export const deployRemotePreviewApplication = async ({
});
application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs;
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
if (application.serverId) {
let command = "set -e;";
@@ -666,7 +668,6 @@ export const rebuildRemoteApplication = async ({
echo "Error occurred ❌, check the logs for details." >> ${deployment.logPath};
echo "${encodedContent}" | base64 -d >> "${deployment.logPath}";`,
);
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateApplicationStatus(applicationId, "error");
throw error;

View File

@@ -2,18 +2,21 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateDiscord,
type apiCreateEmail,
type apiCreateLark,
type apiCreateGotify,
type apiCreateNtfy,
type apiCreateSlack,
type apiCreateTelegram,
type apiUpdateDiscord,
type apiUpdateEmail,
type apiUpdateLark,
type apiUpdateGotify,
type apiUpdateNtfy,
type apiUpdateSlack,
type apiUpdateTelegram,
discord,
email,
lark,
gotify,
notifications,
ntfy,
@@ -585,6 +588,7 @@ export const findNotificationById = async (notificationId: string) => {
email: true,
gotify: true,
ntfy: true,
lark: true,
},
});
if (!notification) {
@@ -605,6 +609,94 @@ export const removeNotificationById = async (notificationId: string) => {
return result[0];
};
export const createLarkNotification = async (
input: typeof apiCreateLark._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newLark = await tx
.insert(lark)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newLark) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting lark",
});
}
const newDestination = await tx
.insert(notifications)
.values({
larkId: newLark.larkId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "lark",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateLarkNotification = async (
input: typeof apiUpdateLark._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(lark)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(lark.larkId, input.larkId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async (
notificationId: string,
notificationData: Partial<Notification>,

View File

@@ -163,6 +163,24 @@ export const canPerformAccessEnvironment = async (
return false;
};
export const canPerformDeleteEnvironment = async (
userId: string,
projectId: string,
organizationId: string,
) => {
const { accessedProjects, canDeleteEnvironments } = await findMemberById(
userId,
organizationId,
);
const haveAccessToProject = accessedProjects.includes(projectId);
if (canDeleteEnvironments && haveAccessToProject) {
return true;
}
return false;
};
export const canAccessToTraefikFiles = async (
userId: string,
organizationId: string,
@@ -240,6 +258,42 @@ export const checkEnvironmentAccess = async (
}
};
export const checkEnvironmentDeletionPermission = async (
userId: string,
projectId: string,
organizationId: string,
) => {
const member = await findMemberById(userId, organizationId);
if (!member) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found in organization",
});
}
if (member.role === "owner" || member.role === "admin") {
return true;
}
if (!member.canDeleteEnvironments) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to delete environments",
});
}
const hasProjectAccess = member.accessedProjects.includes(projectId);
if (!hasProjectAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
return true;
};
export const checkProjectAccess = async (
authId: string,
action: "create" | "delete" | "access",
@@ -272,6 +326,46 @@ export const checkProjectAccess = async (
}
};
export const checkEnvironmentCreationPermission = async (
userId: string,
projectId: string,
organizationId: string,
) => {
// Get user's member record
const member = await findMemberById(userId, organizationId);
if (!member) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found in organization",
});
}
// Owners and admins can always create environments
if (member.role === "owner" || member.role === "admin") {
return true;
}
// Check if user has canCreateEnvironments permission
if (!member.canCreateEnvironments) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to create environments",
});
}
// Check if user has access to the project
const hasProjectAccess = member.accessedProjects.includes(projectId);
if (!hasProjectAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
return true;
};
export const findMemberById = async (
userId: string,
organizationId: string,

View File

@@ -141,8 +141,8 @@ export function processValue(
}
if (
typeof payload === "string" &&
payload.startsWith("{") &&
payload.endsWith("}")
payload.trimStart().startsWith("{") &&
payload.trimEnd().endsWith("}")
) {
try {
payload = JSON.parse(payload);

View File

@@ -16,6 +16,7 @@ export function getProviderName(apiUrl: string) {
if (apiUrl.includes("api.mistral.ai")) return "mistral";
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
return "custom";
}
@@ -66,6 +67,13 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl,
apiKey: config.apiKey,
});
case "gemini":
return createOpenAICompatible({
name: "gemini",
baseURL: config.apiUrl,
queryParams: { key: config.apiKey },
headers: {},
});
case "custom":
return createOpenAICompatible({
name: "custom",

View File

@@ -1,5 +1,8 @@
import type { WriteStream } from "node:fs";
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
getEnviromentVariablesObject,
prepareEnvironmentVariables,
} from "@dokploy/server/utils/docker/utils";
import {
getBuildAppDirectory,
getDockerContextPath,
@@ -17,6 +20,7 @@ export const buildCustomDocker = async (
env,
publishDirectory,
buildArgs,
buildSecrets,
dockerBuildStage,
cleanCache,
} = application;
@@ -26,11 +30,6 @@ export const buildCustomDocker = async (
const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
const dockerContextPath = getDockerContextPath(application);
@@ -44,9 +43,29 @@ export const buildCustomDocker = async (
commandArgs.push("--target", dockerBuildStage);
}
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) {
commandArgs.push("--build-arg", arg);
}
const secrets = getEnviromentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
);
for (const key in secrets) {
// Although buildx is smart enough to know we may be referring to an environment variable name,
// we still make sure it doesn't fall back to type=file.
// See: https://docs.docker.com/reference/cli/docker/buildx/build/#secret
commandArgs.push("--secret", `type=env,id=${key}`);
}
/*
Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed.
@@ -70,6 +89,10 @@ export const buildCustomDocker = async (
},
{
cwd: dockerContextPath || defaultContextPath,
env: {
...process.env,
...secrets,
},
},
);
} catch (error) {
@@ -86,6 +109,7 @@ export const getDockerCommand = (
env,
publishDirectory,
buildArgs,
buildSecrets,
dockerBuildStage,
cleanCache,
} = application;
@@ -96,11 +120,6 @@ export const getDockerCommand = (
const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
const dockerContextPath =
getDockerContextPath(application) || defaultContextPath;
@@ -115,10 +134,33 @@ export const getDockerCommand = (
commandArgs.push("--no-cache");
}
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) {
commandArgs.push("--build-arg", `'${arg}'`);
}
const secrets = getEnviromentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
);
const joinedSecrets = Object.entries(secrets)
.map(([key, value]) => `${key}='${value.replace(/'/g, "'\"'\"'")}'`)
.join(" ");
for (const key in secrets) {
// Although buildx is smart enough to know we may be referring to an environment variable name,
// we still make sure it doesn't fall back to `type=file`.
// See: https://docs.docker.com/reference/cli/docker/buildx/build/#secret
commandArgs.push("--secret", `type=env,id=${key}`);
}
/*
Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed.
@@ -140,7 +182,7 @@ cd ${dockerContextPath} >> ${logPath} 2>> ${logPath} || {
exit 1;
}
docker ${commandArgs.join(" ")} >> ${logPath} 2>> ${logPath} || {
${joinedSecrets} docker ${commandArgs.join(" ")} >> ${logPath} 2>> ${logPath} || {
echo "❌ Docker build failed" >> ${logPath};
exit 1;
}

View File

@@ -142,6 +142,7 @@ export const mechanizeDockerContainer = async (
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(application);
const bindsMount = generateBindMounts(mounts);
@@ -191,6 +192,8 @@ export const mechanizeDockerContainer = async (
})),
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -45,6 +45,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(mariadb);
const resources = calculateResources({
memoryLimit,
@@ -102,6 +103,8 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -91,6 +91,7 @@ ${command ?? "wait $MONGOD_PID"}`;
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(mongo);
const resources = calculateResources({
@@ -155,6 +156,8 @@ ${command ?? "wait $MONGOD_PID"}`;
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -51,6 +51,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(mysql);
const resources = calculateResources({
memoryLimit,
@@ -108,6 +109,8 @@ export const buildMysql = async (mysql: MysqlNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -44,6 +44,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(postgres);
const resources = calculateResources({
memoryLimit,
@@ -101,6 +102,8 @@ export const buildPostgres = async (postgres: PostgresNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -42,6 +42,7 @@ export const buildRedis = async (redis: RedisNested) => {
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
} = generateConfigContainer(redis);
const resources = calculateResources({
memoryLimit,
@@ -98,6 +99,8 @@ export const buildRedis = async (redis: RedisNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -394,8 +394,14 @@ export const generateConfigContainer = (
replicas,
mounts,
networkSwarm,
stopGracePeriodSwarm,
} = application;
const sanitizedStopGracePeriodSwarm =
typeof stopGracePeriodSwarm === "bigint"
? Number(stopGracePeriodSwarm)
: stopGracePeriodSwarm;
const haveMounts = mounts && mounts.length > 0;
return {
@@ -444,6 +450,10 @@ export const generateConfigContainer = (
Order: "start-first",
},
}),
...(sanitizedStopGracePeriodSwarm !== null &&
sanitizedStopGracePeriodSwarm !== undefined && {
StopGracePeriod: sanitizedStopGracePeriodSwarm,
}),
...(networkSwarm
? {
Networks: networkSwarm,

View File

@@ -7,6 +7,7 @@ import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
@@ -44,11 +45,13 @@ export const sendBuildErrorNotifications = async ({
slack: true,
gotify: true,
ntfy: true,
lark: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, discord, telegram, slack, gotify, ntfy, lark } =
notification;
if (email) {
const template = await renderAsync(
BuildFailedEmail({
@@ -211,5 +214,117 @@ export const sendBuildErrorNotifications = async ({
],
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "⚠️ Build Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Type:**\n${applicationType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
{
tag: "button",
text: {
tag: "plain_text",
content: "View Build Details",
},
type: "danger",
width: "default",
size: "medium",
behaviors: [
{
type: "open_url",
default_url: buildLink,
pc_url: "",
ios_url: "",
android_url: "",
},
],
margin: "0px 0px 0px 0px",
},
],
},
},
});
}
}
};

View File

@@ -8,6 +8,7 @@ import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
@@ -45,11 +46,13 @@ export const sendBuildSuccessNotifications = async ({
slack: true,
gotify: true,
ntfy: true,
lark: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, discord, telegram, slack, gotify, ntfy, lark } =
notification;
if (email) {
const template = await renderAsync(
@@ -210,5 +213,109 @@ export const sendBuildSuccessNotifications = async ({
],
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Build Success",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Type:**\n${applicationType}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
{
tag: "button",
text: {
tag: "plain_text",
content: "View Build Details",
},
type: "primary",
width: "default",
size: "medium",
behaviors: [
{
type: "open_url",
default_url: buildLink,
pc_url: "",
ios_url: "",
android_url: "",
},
],
margin: "0px 0px 0px 0px",
},
],
},
},
});
}
}
};

View File

@@ -7,6 +7,7 @@ import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
@@ -44,11 +45,13 @@ export const sendDatabaseBackupNotifications = async ({
slack: true,
gotify: true,
ntfy: true,
lark: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, discord, telegram, slack, gotify, ntfy, lark } =
notification;
if (email) {
const template = await renderAsync(
@@ -238,5 +241,120 @@ export const sendDatabaseBackupNotifications = async ({
],
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Database Type:**\n${databaseType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Database Name:**\n${databaseName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
}
};

View File

@@ -7,6 +7,7 @@ import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
@@ -31,11 +32,13 @@ export const sendDockerCleanupNotifications = async (
slack: true,
gotify: true,
ntfy: true,
lark: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, discord, telegram, slack, gotify, ntfy, lark } =
notification;
if (email) {
const template = await renderAsync(
@@ -135,5 +138,83 @@ export const sendDockerCleanupNotifications = async (
],
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Docker Cleanup",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Status:**\nSuccessful`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Cleanup Details:**\n${message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
}
}
};

View File

@@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
@@ -25,11 +26,13 @@ export const sendDokployRestartNotifications = async () => {
slack: true,
gotify: true,
ntfy: true,
lark: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, discord, telegram, slack, gotify, ntfy, lark } =
notification;
if (email) {
const template = await renderAsync(
@@ -135,5 +138,81 @@ export const sendDokployRestartNotifications = async () => {
console.log(error);
}
}
if (lark) {
try {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Dokploy Server Restarted",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Status:**\nSuccessful`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Restart Time:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
} catch (error) {
console.log(error);
}
}
}
};

View File

@@ -3,6 +3,7 @@ import { db } from "../../db";
import { notifications } from "../../db/schema";
import {
sendDiscordNotification,
sendLarkNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -34,6 +35,7 @@ export const sendServerThresholdNotifications = async (
discord: true,
telegram: true,
slack: true,
lark: true,
},
});
@@ -41,7 +43,7 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack } = notification;
const { discord, telegram, slack, lark } = notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
@@ -151,5 +153,101 @@ export const sendServerThresholdNotifications = async (
],
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
}
}
};

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