Compare commits

...

259 Commits

Author SHA1 Message Date
Mauricio Siu
82cfe06fa4 Merge pull request #3049 from drudge/canary
chore: change view logs to deployments on preview deployments
2025-11-18 23:02:58 -06:00
Mauricio Siu
19a01665ae fix: simplify getServiceImageDigest command for improved reliability
- Refactored the getServiceImageDigest function to streamline the command used for retrieving the Docker service image digest, enhancing reliability.
- Removed unnecessary console logging for the current digest.
2025-11-18 22:44:42 -06:00
Nicholas Penree
398300f729 chore: change view logs to deployments on preview deployments 2025-11-18 17:02:13 -05:00
Mauricio Siu
605de97805 Correct description text in show-volume-backups.tsx
Fix formatting in volume backups description.
2025-11-18 10:21:44 -06:00
Mauricio Siu
6ba35057ac fix: update getServiceImageDigest to retrieve image digest more reliably
- Refactored the getServiceImageDigest function to use a more robust command for fetching the Docker service image digest, improving accuracy.
- Added console logging for the current digest to aid in debugging and monitoring.
2025-11-18 10:09:02 -06:00
Mauricio Siu
46d1809f84 chore: bump version to v0.25.7 in package.json 2025-11-18 00:27:04 -06:00
Mauricio Siu
ba5e7e2026 fix: improve error handling in getUpdateData function
- Added error logging to the getUpdateData function to capture and display errors when retrieving the current service image digest, enhancing debugging capabilities.
2025-11-18 00:26:38 -06:00
Mauricio Siu
8a741e41bb Merge pull request #2933 from AathilFelix/feat/server-time-clock
feat: add server time clock in the dashboard
2025-11-16 23:01:04 -06:00
Mauricio Siu
1581defc39 feat: conditionally render TimeBadge based on cloud status
- Updated the ShowProjects and side layout components to only display the TimeBadge when not in cloud mode.
- Modified the TimeBadge component to remove the refetch interval for server time when in cloud mode, returning null instead.
- Enhanced the server API to return null for server time in cloud environments, improving performance and avoiding unnecessary queries.
2025-11-16 21:32:23 -06:00
Mauricio Siu
f5891b8793 Merge branch 'canary' into feat/server-time-clock 2025-11-16 21:27:29 -06:00
Mauricio Siu
19244a2dea Merge pull request #3034 from Dokploy/3012-bug-v0256-database-restore-from-s3-gui-fails-due-to-unescaped-special-characters-in-the-database-password
fix: update database restore commands to properly quote user credentials
2025-11-16 15:45:18 -06:00
Mauricio Siu
c4c1930195 fix: update database restore commands to properly quote user credentials
- Modified the restore command functions for PostgreSQL, MariaDB, MySQL, and MongoDB to ensure that database user credentials are enclosed in single quotes. This change enhances command execution reliability and prevents potential issues with special characters in usernames and passwords.
2025-11-16 15:43:46 -06:00
Mauricio Siu
201cc65b09 Merge pull request #3027 from Dokploy/1925-remote-server-visibility-per-applicationservice
feat: enhance environment service to include server details
2025-11-15 23:55:11 -06:00
Mauricio Siu
3618be65fc feat: add server icon display in environment service dashboard
- Introduced a server icon next to services in the environment dashboard for better visual identification of server associations.
- Enhanced user experience by providing immediate visual cues regarding the server linked to each service.
2025-11-15 23:54:53 -06:00
Mauricio Siu
e9b4245625 feat: enhance environment service to include server details
- Added server information retrieval for applications and various database services in the environment service.
- Updated the dashboard to display server names alongside services, allowing for better identification and filtering of services by server.
- Introduced a dropdown filter for selecting services based on server, improving user experience in managing environments.
2025-11-15 23:51:34 -06:00
Mauricio Siu
e60c68dbeb Merge pull request #2989 from Harikrishnan1367709/Better-deployment-logs-for-long-commit-message-#2973
feat: Add expandable commit messages for deployment logs
2025-11-15 17:47:16 -06:00
Mauricio Siu
f46444e039 refactor: simplify deployment description toggle logic
- Removed the separate toggleDescription function and integrated its logic directly into the button's onClick handler for better readability.
- Maintained functionality for expanding and collapsing deployment descriptions while streamlining the code structure.
2025-11-15 17:46:14 -06:00
Mauricio Siu
05e3d241f1 feat: increase commit message truncation length and simplify truncation logic
- Updated the maximum character length for commit message truncation from 150 to 200 characters.
- Simplified the truncation logic by removing unnecessary checks and consolidating the function to focus solely on the new maximum length.
- Enhanced the display logic for deployment titles to ensure better readability and user experience.
2025-11-15 17:43:51 -06:00
Mauricio Siu
5c2bae2f21 Merge branch 'canary' into Better-deployment-logs-for-long-commit-message-#2973 2025-11-15 17:34:48 -06:00
Mauricio Siu
d854979fe3 Merge pull request #2984 from ChillerDragon/pr_fix_template_checkboxes
fix: pr template checkboxes
2025-11-15 17:34:20 -06:00
Mauricio Siu
8016708798 Merge pull request #3021 from Dokploy/1735-bug-in-server-monitoring
1735 bug in server monitoring
2025-11-15 01:00:40 -06:00
Mauricio Siu
09a98a29e0 fix: remove unnecessary console log from Docker stats monitoring 2025-11-15 00:59:36 -06:00
Mauricio Siu
a4caa47e10 feat: implement host system stats retrieval for Docker monitoring
- Added a new function `getHostSystemStats` to encapsulate the logic for retrieving host system statistics using `node-os-utils`.
- Refactored Docker stats monitoring to utilize the new function, improving code clarity and maintainability.
- Removed redundant OSUtils instantiation from the Docker stats monitoring logic.
2025-11-15 00:59:00 -06:00
Mauricio Siu
969147cd59 feat: enhance Docker stats monitoring with disk I/O statistics
- Updated OSUtils instantiation to include disk I/O statistics.
- Implemented filtering to exclude virtual devices from disk stats, ensuring only real disk devices are monitored.
2025-11-15 00:56:05 -06:00
Mauricio Siu
6369012389 Merge pull request #3020 from Dokploy/1735-bug-in-server-monitoring
chore: update node-os-utils to version 2.0.1 and refactor lodash imports
2025-11-15 00:30:39 -06:00
Mauricio Siu
69b7777db4 chore: update node-os-utils to version 2.0.1 and refactor lodash imports
- Upgraded `node-os-utils` from version 1.3.7 to 2.0.1 across multiple package.json files.
- Removed deprecated `@types/node-os-utils` dependency.
- Refactored lodash imports to use a single import statement for consistency.
- Enhanced Docker stats monitoring by integrating new features from `node-os-utils` version 2.0.1.
2025-11-15 00:28:44 -06:00
Mauricio Siu
b9324e6320 Merge pull request #3019 from Dokploy/fix/clean-railpack-builder-after-build
fix: ensure proper cleanup of Docker buildx builder container
2025-11-14 23:10:12 -06:00
Mauricio Siu
04a1a84077 fix: ensure proper cleanup of Docker buildx builder container
- Added commands to remove the builder container after Railpack build and prepare failures to prevent resource leaks.
- Improved bash command structure for better readability and maintenance.
2025-11-14 23:09:02 -06:00
Mauricio Siu
735b70b7fe Merge pull request #3018 from Dokploy/2508-git-based-deployments-should-have-git-hash-and-commit-message-on-deploy-manually
feat: add git commit info extraction to deployment logic
2025-11-14 22:31:36 -06:00
Mauricio Siu
61d9ae397a feat: add git commit info extraction to deployment logic
- Integrated `getGitCommitInfo` function to retrieve the latest commit message and hash for applications and compose services.
- Updated deployment logic to conditionally include commit information in deployment updates, enhancing traceability.
- Refactored import statements for better organization and clarity.
2025-11-14 22:27:38 -06:00
Mauricio Siu
ea5d86e295 Merge pull request #3000 from Bima42/fix/bump-traefik-version
chore: bump traefik to 3.6.1
2025-11-14 22:12:30 -06:00
Mauricio Siu
dd06c7006d Merge pull request #2513 from divaltor/docker-image-tag
feat(tags): Add support for tags from Github Packages
2025-11-14 01:35:39 -06:00
Mauricio Siu
4d36741e50 refactor: streamline service extraction logic in add-permissions component
- Updated type definitions for Environment and Project to improve clarity and maintainability.
- Refactored the extractServices function to use optional chaining and nullish coalescing for safer data handling.
- Enhanced type safety by casting the mapped services to the Services type.
2025-11-14 01:33:07 -06:00
Mauricio Siu
a9b9dd4b66 fix: conditionally include deployment hash in job data logging 2025-11-14 01:14:35 -06:00
Mauricio Siu
fbb1f1f266 fix: remove unnecessary log statement in Docker deploy validation 2025-11-14 01:11:52 -06:00
Mauricio Siu
c35fe0d457 feat: enhance Docker image handling in deployment logic
- Added functions to extract image name and tag from Docker images and webhook requests.
- Implemented validation for Docker image names and tags during deployment.
- Expanded test coverage for image tag extraction and commit message generation for GitHub Packages events.
- Improved error handling for missing image names and tags in deployment requests.
2025-11-14 01:10:49 -06:00
Mauricio Siu
ec081b6f2e Merge branch 'canary' into docker-image-tag 2025-11-13 22:55:55 -06:00
Mauricio Siu
4518ea2092 Merge pull request #3010 from Dokploy/Improve-project-view-by-showing-last-deploy-rather-than-created
feat: add last deployment date to services and update sorting logic
2025-11-13 22:36:48 -06:00
Mauricio Siu
d549aa6a62 feat: add last deployment date to services and update sorting logic
- Introduced `lastDeployDate` property to track the most recent deployment for applications and compose services.
- Updated the `extractServicesFromEnvironment` function to calculate and include the last deployment date.
- Modified sorting logic to allow sorting by last deployment date, enhancing the user experience on the environment dashboard.
- Adjusted local storage default sort preference to prioritize last deployment date.
2025-11-13 22:35:16 -06:00
Mauricio Siu
62474c1222 Merge pull request #2978 from Dokploy/unify-deployment-logic
Refactor compose and deployment services: streamline cloning and exec…
2025-11-13 22:25:50 -06:00
Mauricio Siu
26ff4075df Merge branch 'canary' into unify-deployment-logic 2025-11-13 21:06:00 -06:00
Mauricio Siu
22f704dd59 Merge pull request #2988 from Harikrishnan1367709/Invitation-Link-broken-on-Mac-#2986
fix: Add protocol prefix to invitation links (#2986)
2025-11-13 12:35:52 -06:00
Bima42
d22aa0583c chore: bump traefik to 3.6.1 2025-11-13 16:17:21 +01:00
autofix-ci[bot]
70bb32c590 [autofix.ci] apply automated fixes 2025-11-11 07:42:12 +00:00
HarikrishnanD
843313ddb9 feat: add expandable commit messages for deployment logs 2025-11-11 13:10:47 +05:30
HarikrishnanD
b202974a7d fix: add protocol prefix to invitation links 2025-11-11 11:34:10 +05:30
ChillerDragon
c56ddf3ec1 fix: pr template checkboxes
without a space they do not render as checkboxes on github
2025-11-10 11:12:00 +01:00
Mauricio Siu
b814bdc612 Refactor application and compose deployment logic: remove unused buildApplication function, streamline command logging for deployment, and enhance static command generation for improved maintainability and clarity in the codebase. 2025-11-09 11:13:39 -06:00
Mauricio Siu
d8ab7a59ff Refactor Bitbucket header utility: remove unused BitbucketClone type definition to streamline the code and enhance maintainability. 2025-11-09 03:43:54 -06:00
Mauricio Siu
f718ab334e Refactor compose utilities: remove unused functions and streamline the buildCompose logic for improved maintainability. Update domain handling by retaining only the necessary remote function, enhancing clarity in the codebase. 2025-11-09 03:42:43 -06:00
Mauricio Siu
668aaf9a91 Refactor deployment utilities: rename remote deployment functions for clarity and consistency, enhancing the deployment logic in the application. Streamline the build application function by commenting out unused build types to improve maintainability. 2025-11-09 03:29:40 -06:00
Mauricio Siu
ef10996dd8 Refactor builder utilities: remove unused build functions for Docker, Heroku, Nixpacks, Paketo, and Railpack, streamlining the codebase. Update static command generation to enhance clarity and maintainability. 2025-11-09 03:28:32 -06:00
Mauricio Siu
a05b75fc67 Refactor deployment logic: remove unused remote preview deployment function, streamline deployment commands, and enhance error handling for Docker image pulling. Update build command generation for Docker source type. 2025-11-09 03:24:13 -06:00
Mauricio Siu
f96114ad80 Refactor Bitbucket repository cloning logic: remove unused parameter and enhance error handling by retrieving Bitbucket provider directly within the function. 2025-11-09 03:18:07 -06:00
Mauricio Siu
5ac32f9f24 Refactor repository cloning interfaces: standardize parameters for Bitbucket, Gitea, and GitLab repository cloning functions to improve consistency and maintainability across the codebase. 2025-11-09 03:16:18 -06:00
Mauricio Siu
7b398939f7 Refactor compose and deployment services: streamline cloning and execution commands, remove redundant remote functions, and enhance error handling. Update database schema to include application build server ID for better tracking of deployments. 2025-11-09 03:12:49 -06:00
Mauricio Siu
fd8f0e8f1f Merge pull request #2950 from Bima42/fix/2949-upload-in-dropzone-two-times-in-a-row
fix: clear input value after uploading file in dropzone
2025-11-08 14:20:46 -06:00
Mauricio Siu
4f2268e66f Merge pull request #2976 from Dokploy/2838-endpoint-mode-configuration-solves-networking-issues-inside-lxc-containers
Refactor user schema and update database references: rename 'users_te…
2025-11-08 14:13:40 -06:00
Mauricio Siu
b99d532582 Update tests and refactor user query: Add 'endpointSpecSwarm' to application test cases and rename user variable in Stripe webhook to improve clarity and consistency. 2025-11-08 14:12:01 -06:00
Mauricio Siu
fb2bb99a2c Add SQL migration for user table refactor and endpoint specifications: Rename 'user_temp' to 'user', drop and add foreign key constraints, and introduce 'endpointSpecSwarm' column in multiple tables. Update journal and snapshot metadata for migration 0120_lame_captain_midlands. 2025-11-08 14:09:26 -06:00
Mauricio Siu
785172fa7b Enhance application schema and database utilities: Add 'endpointSpecSwarm' to application schema, update Docker container configuration to handle 'EndpointSpec' more flexibly across various database implementations, and remove deprecated 'generateEndpointSpec' function to streamline codebase. 2025-11-08 14:08:14 -06:00
Mauricio Siu
43701915f1 Add SQL migration for user table refactor: rename 'users' to 'user', update foreign key constraints, and add unique email constraint. Update journal and snapshot metadata for migration 0121_colorful_star_brand. 2025-11-08 13:57:05 -06:00
Mauricio Siu
2619733915 Refactor user schema and update database references: rename 'users_temp' to 'user' across the codebase, update related database queries, and enhance endpoint specifications for swarm settings in various database schemas. 2025-11-08 13:54:32 -06:00
Bima42
8aa496b773 fix: clear input value after uploading file in dropzone 2025-11-03 19:03:19 +01:00
Mauricio Siu
1ce153371a Merge pull request #2930 from Harikrishnan1367709/Add-the-ability-to-mark-an-organization-as--default--or-remember-last-used-organization-#1991
feat: Add ability to mark organization as default (#1991)
2025-11-02 22:33:38 -06:00
Mauricio Siu
41849654a7 Refactor Sidebar organization actions: streamline default organization setting and deletion logic, ensuring proper error handling and UI consistency. 2025-11-02 22:32:28 -06:00
Mauricio Siu
a475361b80 Refactor organization management in Sidebar: streamline organization selection and default setting logic. Update API to return organization memberships with default status. Improve UI for organization actions in the sidebar. 2025-11-02 22:27:04 -06:00
Mauricio Siu
1dc5bbd9bd Add 'is_default' column to 'member' table and update journal and snapshot metadata for migration 0119_bouncy_morbius 2025-11-02 22:07:20 -06:00
Mauricio Siu
d55e934978 Remove deprecated SQL migration file '0120_plain_eternity.sql' and corresponding entries from journal and snapshot metadata to clean up project structure. 2025-11-02 22:05:38 -06:00
Mauricio Siu
dddb866233 Remove 'is_default' field from snapshot metadata in 0114_snapshot.json to streamline project permissions configuration. 2025-11-02 22:04:05 -06:00
Mauricio Siu
0b58092c8a Remove deprecated SQL migration file and add new migration for default member organization flag. Update journal and snapshot metadata accordingly. 2025-11-02 22:03:34 -06:00
Mauricio Siu
759955e05e Delete apps/dokploy/drizzle/0114_sudden_sheva_callister.sql 2025-11-02 22:02:09 -06:00
Mauricio Siu
5949005458 Remove deprecated SQL migration file and add new migration for default member organization flag. Update journal and snapshot metadata accordingly. 2025-11-02 21:57:43 -06:00
Mauricio Siu
71b550f7e6 Merge branch 'canary' into Add-the-ability-to-mark-an-organization-as--default--or-remember-last-used-organization-#1991 2025-11-02 21:41:08 -06:00
Mauricio Siu
832a98734a Merge pull request #2943 from Dokploy/2905-subdomain-length-of-random-traefik-domain-isnt-checked-and-exceeds-maximum
feat(domain): truncate project name to comply with domain label lengt…
2025-11-02 17:21:41 -06:00
Mauricio Siu
65b3ce831f feat(domain): truncate project name to comply with domain label length restrictions 2025-11-02 17:20:42 -06:00
Mauricio Siu
6613cb7587 Merge pull request #2921 from Harikrishnan1367709/Container-not-started-if-the-Volume-contains-spaces-#2916
feat(volumes): block spaces/quotes in volume names (#2916)
2025-11-02 17:16:13 -06:00
Mauricio Siu
75a43896a2 Merge pull request #2941 from AtilMohAmine/fix/empty-json-responses-compose
Fix: Add JSON responses to compose endpoints that return empty body
2025-11-02 17:14:31 -06:00
test
64e48a7bbe fix: add JSON responses to compose endpoints that return empty body 2025-11-02 18:16:08 +01:00
Mauricio Siu
5434d9730d Merge pull request #2937 from amirparsadd/amirparsadd-patch-1
ArvanCloud new IP Ranges
2025-11-01 22:41:08 -06:00
Amirparsa Baghdadi
373c78a927 ArvanCloud new IP Ranges 2025-11-01 23:10:58 +03:30
Aathil Felix
53b66e41e2 chore(ui): apply Biome format to time badge and headers 2025-11-01 19:09:58 +05:30
Aathil Felix
0f100c7bc8 feat: add server time clock 2025-11-01 18:03:40 +05:30
autofix-ci[bot]
856b6ceec6 [autofix.ci] apply automated fixes 2025-10-31 14:53:42 +00:00
HarikrishnanD
a14cc09933 feat: Add default organization selection (#1991) 2025-10-31 20:21:49 +05:30
HarikrishnanD
94c00312c1 feat(volumes): reject spaces/quotes in volume names per Docker rules (#2916) 2025-10-30 12:54:37 +05:30
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
Vlad Vladov
6da122eab7 feat(tags): Add support for tags from Github Packages 2025-09-03 18:05:22 +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
164 changed files with 51929 additions and 3608 deletions

View File

@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
Before submitting this PR, please make sure that:
- [] You created a dedicated branch based on the `canary` branch.
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [] You have tested this PR in your local instance.
- [ ] You created a dedicated branch based on the `canary` branch.
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [ ] You have tested this PR in your local instance.
## Issues related (if applicable)

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

@@ -1,9 +1,9 @@
import {
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
deployApplication,
deployCompose,
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -16,13 +16,13 @@ export const deploy = async (job: DeployJob) => {
await updateApplicationStatus(job.applicationId, "running");
if (job.server) {
if (job.type === "redeploy") {
await rebuildRemoteApplication({
await rebuildApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteApplication({
await deployApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -36,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
if (job.server) {
if (job.type === "redeploy") {
await rebuildRemoteCompose({
await rebuildCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteCompose({
await deployCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -55,7 +55,7 @@ export const deploy = async (job: DeployJob) => {
});
if (job.server) {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import {
extractCommitMessage,
extractImageName,
extractImageTag,
extractImageTagFromRequest,
} from "@/pages/api/deploy/[refreshToken]";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
);
});
});
describe("GitHub Packages Docker Image Tag Extraction", () => {
it("should extract tag from container_metadata", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "v1.0.0",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:v1.0.0",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("v1.0.0");
});
it("should extract tag from package_url when container_metadata tag matches version", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should extract tag from package_url when container_metadata is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:1.2.3",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("1.2.3");
});
it("should handle different tag formats in package_url", () => {
const headers = { "x-github-event": "registry_package" };
const testCases = [
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
];
for (const testCase of testCases) {
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: testCase.url,
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe(testCase.expected);
}
});
it("should return null for non-registry_package events", () => {
const headers = { "x-github-event": "push" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url has no tag", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url ends with colon (no tag)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when tag name is empty string", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should ignore tag if it matches the version (digest)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should handle registry_package commit message with package_url", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
});
it("should handle registry_package commit message when package_url is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed");
});
it("should handle registry_package commit message when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("NEW COMMIT");
});
});
describe("Docker Image Name and Tag Extraction", () => {
describe("extractImageName", () => {
it("should return image name without tag", () => {
expect(extractImageName("my-image:latest")).toBe("my-image");
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
"ghcr.io/owner/repo",
);
});
it("should return full image name when no tag is present", () => {
expect(extractImageName("my-image")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
});
it("should handle images with port numbers correctly", () => {
expect(extractImageName("registry:5000/image:tag")).toBe(
"registry:5000/image",
);
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
"localhost:5000/my-app",
);
});
it("should handle complex image paths", () => {
expect(
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("myregistryhost:5000/fedora/httpd");
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"registry.example.com:8080/ns/app",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageName(null)).toBeNull();
expect(extractImageName("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageName("image:tag:extra")).toBe("image:tag");
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
});
});
describe("extractImageTag", () => {
it("should extract tag from image with tag", () => {
expect(extractImageTag("my-image:latest")).toBe("latest");
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
});
it("should return 'latest' when no tag is present", () => {
expect(extractImageTag("my-image")).toBe("latest");
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
});
it("should handle complex image paths with tags", () => {
expect(
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("version1.0");
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"v1.2.3",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageTag(null)).toBeNull();
expect(extractImageTag("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageTag("image:tag:extra")).toBe("extra");
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
});
it("should handle numeric tags", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
});
});

View File

@@ -42,12 +42,14 @@ const baseApp: ApplicationNested = {
triggerType: "push",
appName: "",
autoDeploy: true,
endpointSpecSwarm: null,
serverId: "",
registryUrl: "",
branch: null,
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -73,6 +75,7 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",
@@ -133,6 +136,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

@@ -15,6 +15,7 @@ const baseApp: ApplicationNested = {
giteaId: "",
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,
appName: "",
autoDeploy: true,
enableSubmodules: false,
@@ -25,8 +26,10 @@ const baseApp: ApplicationNested = {
registryUrl: "",
watchPaths: [],
buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
@@ -111,6 +114,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,
@@ -121,6 +122,22 @@ const NetworkSwarmSchema = z.array(
const LabelsSwarmSchema = z.record(z.string());
const EndpointPortConfigSwarmSchema = z
.object({
Protocol: z.string().optional(),
TargetPort: z.number().optional(),
PublishedPort: z.number().optional(),
PublishMode: z.string().optional(),
})
.strict();
const EndpointSpecSwarmSchema = z
.object({
Mode: z.string().optional(),
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
})
.strict();
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
return z
.string()
@@ -176,10 +193,21 @@ const addSwarmSettings = z.object({
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: createStringToJSONSchema(
EndpointSpecSwarmSchema,
).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 +252,23 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: null,
labelsSwarm: null,
networkSwarm: null,
stopGracePeriodSwarm: null,
endpointSpecSwarm: 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 +294,10 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
networkSwarm: data.networkSwarm
? JSON.stringify(data.networkSwarm, null, 2)
: null,
stopGracePeriodSwarm: normalizedStopGracePeriod,
endpointSpecSwarm: data.endpointSpecSwarm
? JSON.stringify(data.endpointSpecSwarm, null, 2)
: null,
});
}
}, [form, form.reset, data]);
@@ -275,6 +318,8 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: data.modeSwarm,
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
endpointSpecSwarm: data.endpointSpecSwarm,
})
.then(async () => {
toast.success("Swarm settings updated");
@@ -352,9 +397,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 +452,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 +574,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 +632,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 +819,118 @@ 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>
)}
/>
<FormField
control={form.control}
name="endpointSpecSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Endpoint Spec</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Mode?: string | undefined;
Ports?: Array<{
Protocol?: string | undefined;
TargetPort?: number | undefined;
PublishedPort?: number | undefined;
PublishMode?: string | undefined;
}> | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Mode": "dnsrr",
"Ports": [
{
"Protocol": "tcp",
"TargetPort": 5432,
"PublishedPort": 5432,
"PublishMode": "host"
}
]
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</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

@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
volumeName: z
.string()
.min(1, "Volume name required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
})
.merge(mountSchema),
z
@@ -318,7 +324,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 +333,7 @@ export const AddVolumes = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
className="h-96 font-mono "
{...field}
/>
</FormControl>

View File

@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
volumeName: z
.string()
.min(1, "Volume name required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
})
.merge(mountSchema),
z

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

@@ -1,4 +1,12 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import {
ChevronDown,
ChevronUp,
Clock,
Loader2,
RefreshCcw,
RocketIcon,
Settings,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -80,6 +88,23 @@ export const ShowDeployments = ({
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
new Set(),
);
const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
return `${truncated.slice(0, lastSpace)}...`;
}
return `${truncated}...`;
};
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
@@ -217,118 +242,164 @@ export const ShowDeployments = ({
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment, index) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
{deployments?.map((deployment, index) => {
const titleText = deployment?.title?.trim() || "";
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
return (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
<div className="flex flex-row items-center gap-2">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
description="Are you sure you want to kill the process?"
type="default"
onClick={async () => {
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error killing process");
});
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isKillingProcess}
<div className="flex flex-col gap-1">
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
{isExpanded || !needsTruncation
? titleText
: truncateDescription(titleText)}
</span>
{needsTruncation && (
<button
type="button"
onClick={() => {
const next = new Set(expandedDescriptions);
if (next.has(deployment.deploymentId)) {
next.delete(deployment.deploymentId);
} else {
next.add(deployment.deploymentId);
}
setExpandedDescriptions(next);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
aria-label={
isExpanded
? "Collapse commit message"
: "Expand commit message"
}
>
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{isExpanded ? (
<>
<ChevronUp className="size-3" />
Show less
</>
) : (
<>
<ChevronDown className="size-3" />
Show more
</>
)}
</button>
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<div className="flex flex-row items-center gap-2">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
title="Kill Process"
description="Are you sure you want to kill the process?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error initiating rollback");
toast.error("Error killing process");
});
}}
>
<Button
variant="secondary"
variant="destructive"
size="sm"
isLoading={isRollingBack}
isLoading={isKillingProcess}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
<ShowDeployment

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

@@ -182,7 +182,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
id={deployment.previewDeploymentId}
type="previewDeployment"
serverId={data?.serverId || ""}
/>
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<RocketIcon className="size-4" />
Deployments
</Button>
</ShowDeploymentsModal>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}

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

@@ -47,7 +47,13 @@ const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
volumeName: z.string().min(1, "Volume name is required"),
volumeName: z
.string()
.min(1, "Volume name is required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
prefix: z.string(),
keepLatestCount: z.coerce
.number()

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,34 +53,46 @@ 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
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals.
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,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import _ from "lodash";
import {
CheckIcon,
ChevronsUpDown,
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = debounce((value: string) => {
const debouncedSetSearch = _.debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 350);

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

@@ -1,5 +1,5 @@
import { FancyAnsi } from "fancy-ansi";
import { escapeRegExp } from "lodash";
import _ from "lodash";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
}
const htmlContent = fancyAnsi.toHtml(text);
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
const modifiedContent = htmlContent.replace(
searchRegex,

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

@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
@@ -44,7 +45,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -52,12 +52,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
@@ -135,6 +137,11 @@ export const ShowProjects = () => {
<BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-5 right-5">
<TimeBadge />
</div>
)}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
@@ -148,7 +155,6 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className="">
<HandleProject />
@@ -298,7 +304,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
@@ -340,7 +352,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}

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,5 @@
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";
@@ -27,12 +26,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { api, type RouterOutputs } from "@/utils/api";
type Environment = Omit<
Awaited<ReturnType<typeof findEnvironmentById>>,
"project"
>;
type Project = RouterOutputs["project"]["all"][number];
type Environment = Project["environments"][number];
export type Services = {
appName: string;
@@ -53,17 +50,16 @@ export type Services = {
};
export const extractServices = (data: Environment | undefined) => {
const applications: Services[] =
data?.applications.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const applications: Services[] = (data?.applications?.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
@@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => {
serverId: item.serverId,
})) || [];
const compose: Services[] =
data?.compose.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const compose: Services[] = (data?.compose?.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
applications.push(
...mysql,
@@ -161,11 +156,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 +172,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 +190,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 +217,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 +235,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 +244,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 +358,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

@@ -26,6 +26,7 @@ import {
PieChart,
Server,
ShieldCheck,
Star,
Trash2,
User,
Users,
@@ -82,6 +83,7 @@ import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { TimeBadge } from "../ui/time-badge";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
@@ -497,7 +499,6 @@ function SidebarLogo() {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
@@ -505,6 +506,8 @@ function SidebarLogo() {
} = api.organization.all.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
@@ -594,66 +597,127 @@ function SidebarLogo() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => (
<div className="flex flex-row justify-between" key={org.name}>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-4">{org.name}</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
{org.ownerId === session?.user?.id && (
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
)}
</div>
))}
</div>
);
})}
{(user?.role === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
@@ -1062,6 +1126,7 @@ export default function Page({ children }: Props) {
</BreadcrumbList>
</Breadcrumb>
</div>
{!isCloud && <TimeBadge />}
</div>
</header>
)}

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

@@ -67,9 +67,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
ref={inputRef}
type="file"
className={cn("hidden", className)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onChange(e.target.files)
}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.files);
e.target.value = "";
}}
/>
</div>
</CardContent>

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/utils/api";
export function TimeBadge() {
const { data: serverTime } = api.server.getServerTime.useQuery(undefined);
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
if (serverTime?.time) {
setTime(new Date(serverTime.time));
}
}, [serverTime]);
useEffect(() => {
const timer = setInterval(() => {
setTime((prevTime) => {
if (!prevTime) return null;
const newTime = new Date(prevTime.getTime() + 1000);
return newTime;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
if (!time || !serverTime?.timezone) {
return null;
}
const getUtcOffset = (timeZone: string) => {
const date = new Date();
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
const sign = offset >= 0 ? "+" : "-";
const hours = Math.floor(Math.abs(offset));
const minutes = (Math.abs(offset) * 60) % 60;
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
};
return (
<div className="inline-flex items-center gap-2 rounded-md border px-2 py-1 text-xs sm:text-sm whitespace-nowrap max-w-full overflow-hidden">
<span className="hidden sm:inline">Server Time:</span>
<span className="font-medium tabular-nums">
{time.toLocaleTimeString()}
</span>
<span className="hidden sm:inline text-muted-foreground">
({serverTime.timezone} | {getUtcOffset(serverTime.timezone)})
</span>
</div>
);
}

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;

View File

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

View File

@@ -0,0 +1,39 @@
ALTER TABLE "user_temp" RENAME TO "user";--> statement-breakpoint
ALTER TABLE "user" DROP CONSTRAINT "user_temp_email_unique";--> statement-breakpoint
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "two_factor" DROP CONSTRAINT "two_factor_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "backup" DROP CONSTRAINT "backup_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");

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

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,55 @@
"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
},
{
"idx": 119,
"version": "7",
"when": 1762142756443,
"tag": "0119_bouncy_morbius",
"breakpoints": true
},
{
"idx": 120,
"version": "7",
"when": 1762632540024,
"tag": "0120_lame_captain_midlands",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.4",
"version": "v0.25.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -120,7 +120,7 @@
"next": "^15.3.2",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"node-os-utils": "1.3.7",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
@@ -163,7 +163,6 @@
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",

View File

@@ -12,6 +12,17 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
/**
* Helper function to get package_version from registry_package events
*/
const getPackageVersion = (headers: any, body: any) => {
const event = headers["x-github-event"];
if (event === "registry_package") {
return body.registry_package?.package_version;
}
return null;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@@ -46,21 +57,60 @@ export default async function handler(
}
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const sourceType = application.sourceType;
if (sourceType === "docker") {
const applicationImageName = extractImageName(application.dockerImage);
const applicationDockerTag = extractImageTag(application.dockerImage);
const webhookImageName = extractImageNameFromRequest(
req.headers,
req.body,
);
const webhookDockerTag = extractImageTagFromRequest(
req.headers,
req.body,
);
if (
applicationDockerTag &&
webhookDockerTag &&
webhookDockerTag !== applicationDockerTag
) {
if (!applicationImageName) {
res.status(301).json({
message: "Application Docker Image Name Not Found",
});
return;
}
if (!webhookImageName) {
res.status(301).json({
message: "Webhook Docker Image Name Not Found",
});
return;
}
// Validate image name matches
if (webhookImageName !== applicationImageName) {
res.status(301).json({
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
});
return;
}
if (!applicationDockerTag) {
res.status(301).json({
message: "Application Docker Tag Not Found",
});
return;
}
if (!webhookDockerTag) {
res.status(301).json({
message: "Webhook Docker Tag Not Found",
});
return;
}
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
@@ -191,7 +241,7 @@ export default async function handler(
const jobData: DeploymentJob = {
applicationId: application.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
...(deploymentHash && { descriptionLog: `Hash: ${deploymentHash}` }),
type: "deploy",
applicationType: "application",
server: !!application.serverId,
@@ -222,6 +272,39 @@ export default async function handler(
}
}
/**
* Return the image name without the tag
* Example: "my-image" => "my-image"
* Example: "my-image:latest" => "my-image"
* Example: "my-image:1.0.0" => "my-image"
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "myregistryhost:5000/fedora/httpd"
* @link https://docs.docker.com/reference/cli/docker/image/tag/
*/
export function extractImageName(dockerImage: string | null): string | null {
if (!dockerImage || typeof dockerImage !== "string") {
return null;
}
// Handle case where there's no tag (no colon or colon is part of port number)
const lastColonIndex = dockerImage.lastIndexOf(":");
if (lastColonIndex === -1) {
return dockerImage;
}
// Check if the part after the last colon looks like a tag (not a port number)
// Port numbers are typically 1-5 digits, tags are usually longer or contain letters
const afterColon = dockerImage.substring(lastColonIndex + 1);
const isPortNumber = /^\d{1,5}$/.test(afterColon);
// If it's a port number (like registry:5000/image), don't split
if (isPortNumber) {
return dockerImage;
}
// Otherwise, split at the last colon to get image name
return dockerImage.substring(0, lastColonIndex);
}
/**
* Return the last part of the image name, which is the tag
* Example: "my-image" => null
@@ -230,7 +313,7 @@ export default async function handler(
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "version1.0"
* @link https://docs.docker.com/reference/cli/docker/image/tag/
*/
function extractImageTag(dockerImage: string | null) {
export function extractImageTag(dockerImage: string | null) {
if (!dockerImage || typeof dockerImage !== "string") {
return null;
}
@@ -240,12 +323,78 @@ function extractImageTag(dockerImage: string | null) {
}
/**
* Extract the image name (without tag) from webhook request
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
*/
export const extractImageNameFromRequest = (
headers: any,
body: any,
): string | null => {
// GitHub Packages: registry_package events (container registry)
const packageVersion = getPackageVersion(headers, body);
if (packageVersion?.package_url) {
const packageUrl = packageVersion.package_url;
// Remove tag if present (everything after the last colon)
if (packageUrl.includes(":")) {
const lastColonIndex = packageUrl.lastIndexOf(":");
// Check if it's a port number (like registry:5000/image)
const afterColon = packageUrl.substring(lastColonIndex + 1);
const isPortNumber = /^\d{1,5}$/.test(afterColon);
if (isPortNumber) {
return packageUrl;
}
return packageUrl.substring(0, lastColonIndex);
}
return packageUrl;
}
// Docker Hub
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.repository) {
const repoName = body.repository.repo_name;
return `${repoName}`;
}
}
return null;
};
/**
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
*/
export const extractImageTagFromRequest = (
headers: any,
body: any,
): string | null => {
// GitHub Packages: registry_package events (container registry)
const packageVersion = getPackageVersion(headers, body);
if (packageVersion) {
// Try to get tag from container_metadata first (most reliable)
// Only use it if it's not empty and not the same as the version (digest)
const tagName = packageVersion.container_metadata?.tag?.name?.trim() || "";
if (
tagName &&
tagName !== packageVersion.version &&
!tagName.startsWith("sha256:")
) {
return tagName;
}
// Fallback: extract tag from package_url (e.g., "ghcr.io/owner/repo:tag")
if (packageVersion.package_url) {
const packageUrl = packageVersion.package_url;
// Handle case where package_url ends with colon (no tag)
if (packageUrl.endsWith(":")) {
return null;
}
const tagMatch = packageUrl.match(/:([^:]+)$/);
if (tagMatch?.[1]?.trim()) {
return tagMatch[1].trim();
}
}
}
// Docker Hub
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return body.push_data.tag;
@@ -255,6 +404,18 @@ export const extractImageTagFromRequest = (
};
export const extractCommitMessage = (headers: any, body: any) => {
// GitHub Packages: registry_package events (container tags)
const githubEvent = headers["x-github-event"];
if (githubEvent === "registry_package") {
const packageVersion = getPackageVersion(headers, body);
if (packageVersion) {
if (packageVersion.package_url) {
return `Docker GHCR image pushed: ${packageVersion.package_url}`;
}
return "Docker GHCR image pushed";
}
// If package_version is missing, fall through to default behavior
}
// GitHub
if (headers["x-github-event"]) {
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
@@ -283,7 +444,7 @@ export const extractCommitMessage = (headers: any, body: any) => {
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return `Docker image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
}
}
@@ -368,14 +529,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

@@ -4,7 +4,7 @@ import { asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { db } from "@/server/db";
import { organization, server, users_temp } from "@/server/db/schema";
import { organization, server, user } from "@/server/db/schema";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -64,13 +64,13 @@ export default async function handler(
session.subscription as string,
);
await db
.update(users_temp)
.update(user)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users_temp.id, adminId))
.where(eq(user.id, adminId))
.returning();
const admin = await findUserById(adminId);
@@ -85,14 +85,12 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users_temp)
.update(user)
.set({
stripeSubscriptionId: newSubscription.id,
stripeCustomerId: newSubscription.customer as string,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
)
.where(eq(user.stripeCustomerId, newSubscription.customer as string))
.returning();
break;
@@ -102,14 +100,12 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users_temp)
.update(user)
.set({
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
const admin = await findUserByStripeCustomerId(
newSubscription.customer as string,
@@ -135,24 +131,20 @@ export default async function handler(
if (newSubscription.status === "active") {
await db
.update(users_temp)
.update(user)
.set({
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
} else {
await disableServers(admin.id);
await db
.update(users_temp)
.update(user)
.set({ serversQuantity: 0 })
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
}
break;
@@ -172,11 +164,11 @@ export default async function handler(
}
await db
.update(users_temp)
.update(user)
.set({
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users_temp.stripeCustomerId, suscription.customer as string));
.where(eq(user.stripeCustomerId, suscription.customer as string));
const admin = await findUserByStripeCustomerId(
suscription.customer as string,
@@ -205,13 +197,11 @@ export default async function handler(
return res.status(400).send("Webhook Error: Admin not found");
}
await db
.update(users_temp)
.update(user)
.set({
serversQuantity: 0,
})
.where(
eq(users_temp.stripeCustomerId, newInvoice.customer as string),
);
.where(eq(user.stripeCustomerId, newInvoice.customer as string));
await disableServers(admin.id);
}
@@ -229,13 +219,13 @@ export default async function handler(
await disableServers(admin.id);
await db
.update(users_temp)
.update(user)
.set({
stripeCustomerId: null,
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(users_temp.stripeCustomerId, customer.id));
.where(eq(user.stripeCustomerId, customer.id));
break;
}
@@ -262,10 +252,10 @@ const disableServers = async (userId: string) => {
};
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
const user = db.query.users_temp.findFirst({
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
const userResult = await db.query.user.findFirst({
where: eq(user.stripeCustomerId, stripeCustomerId),
});
return user;
return userResult;
};
const activateServer = async (serverId: string) => {

View File

@@ -1,4 +1,4 @@
import type { findProjectById } from "@dokploy/server";
import type { findEnvironmentById } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
@@ -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,11 +98,11 @@ 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;
serverId?: string | null;
serverName?: string | null;
name: string;
type:
| "mariadb"
@@ -113,10 +116,10 @@ export type Services = {
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
lastDeployDate?: Date | null;
};
type Project = Awaited<ReturnType<typeof findProjectById>>;
type Environment = Project["environments"][0];
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
export const extractServicesFromEnvironment = (
environment: Environment | undefined,
@@ -126,16 +129,35 @@ export const extractServicesFromEnvironment = (
const allServices: Services[] = [];
const applications: Services[] =
environment.applications?.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
environment.applications?.map((item) => {
// Get the most recent deployment date
let lastDeployDate: Date | null = null;
const deployments = (item as any).deployments;
if (deployments && deployments.length > 0) {
for (const deployment of deployments) {
const deployDate = new Date(
deployment.finishedAt ||
deployment.startedAt ||
deployment.createdAt,
);
if (!lastDeployDate || deployDate > lastDeployDate) {
lastDeployDate = deployDate;
}
}
}
return {
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
lastDeployDate,
};
}) || [];
const mariadb: Services[] =
environment.mariadb?.map((item) => ({
@@ -147,6 +169,7 @@ export const extractServicesFromEnvironment = (
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
})) || [];
const postgres: Services[] =
@@ -159,6 +182,7 @@ export const extractServicesFromEnvironment = (
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
})) || [];
const mongo: Services[] =
@@ -171,6 +195,7 @@ export const extractServicesFromEnvironment = (
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
})) || [];
const redis: Services[] =
@@ -183,6 +208,7 @@ export const extractServicesFromEnvironment = (
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
})) || [];
const mysql: Services[] =
@@ -195,19 +221,39 @@ export const extractServicesFromEnvironment = (
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
})) || [];
const compose: Services[] =
environment.compose?.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
environment.compose?.map((item) => {
// Get the most recent deployment date
let lastDeployDate: Date | null = null;
const deployments = (item as any).deployments;
if (deployments && deployments.length > 0) {
for (const deployment of deployments) {
const deployDate = new Date(
deployment.finishedAt ||
deployment.startedAt ||
deployment.createdAt,
);
if (!lastDeployDate || deployDate > lastDeployDate) {
lastDeployDate = deployDate;
}
}
}
return {
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
serverName: item?.server?.name || null,
lastDeployDate,
};
}) || [];
allServices.push(
...applications,
@@ -235,9 +281,9 @@ const EnvironmentPage = (
const { data: auth } = api.user.get.useQuery();
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "createdAt-desc";
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
}
return "createdAt-desc";
return "lastDeploy-desc";
});
useEffect(() => {
@@ -259,10 +305,45 @@ const EnvironmentPage = (
comparison =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "lastDeploy": {
const aLastDeploy = a.lastDeployDate;
const bLastDeploy = b.lastDeployDate;
if (direction === "desc") {
// For "desc" (newest first): services with deployments first, then those without
if (!aLastDeploy && !bLastDeploy) {
comparison = 0;
} else if (!aLastDeploy) {
comparison = 1; // a (no deploy) goes after b (has deploy)
} else if (!bLastDeploy) {
comparison = -1; // a (has deploy) goes before b (no deploy)
} else {
// Both have deployments: newest first (negative if a is newer)
comparison = bLastDeploy.getTime() - aLastDeploy.getTime();
}
} else {
// For "asc" (oldest first): services with deployments first, then those without
if (!aLastDeploy && !bLastDeploy) {
comparison = 0;
} else if (!aLastDeploy) {
comparison = 1; // a (no deploy) goes after b (has deploy)
} else if (!bLastDeploy) {
comparison = -1; // a (has deploy) goes before b (no deploy)
} else {
// Both have deployments: oldest first
comparison = aLastDeploy.getTime() - bLastDeploy.getTime();
}
}
break;
}
default:
comparison = 0;
}
return direction === "asc" ? comparison : -comparison;
// For other fields, apply direction normally
if (field !== "lastDeploy") {
return direction === "asc" ? comparison : -comparison;
}
return comparison;
});
};
@@ -318,6 +399,7 @@ const EnvironmentPage = (
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [deleteVolumes, setDeleteVolumes] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string>("all");
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
@@ -707,6 +789,27 @@ const EnvironmentPage = (
setIsBulkActionLoading(false);
};
// Get unique servers from services
const availableServers = useMemo(() => {
if (!applications) return [];
const servers = new Map<string, { serverId: string; serverName: string }>();
applications.forEach((service) => {
if (service.serverId && service.serverName) {
servers.set(service.serverId, {
serverId: service.serverId,
serverName: service.serverName,
});
}
});
return Array.from(servers.values());
}, [applications]);
// Check if there are services without a server (Dokploy server)
const hasServicesWithoutServer = useMemo(() => {
if (!applications) return false;
return applications.some((service) => !service.serverId);
}, [applications]);
const filteredServices = useMemo(() => {
if (!applications) return [];
const filtered = applications.filter(
@@ -715,10 +818,14 @@ const EnvironmentPage = (
service.description
?.toLowerCase()
.includes(searchQuery.toLowerCase())) &&
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
(selectedTypes.length === 0 || selectedTypes.includes(service.type)) &&
(selectedServerId === "" ||
selectedServerId === "all" ||
(selectedServerId === "dokploy-server" && !service.serverId) ||
service.serverId === selectedServerId),
);
return sortServices(filtered);
}, [applications, searchQuery, selectedTypes, sortBy]);
}, [applications, searchQuery, selectedTypes, selectedServerId, sortBy]);
const selectedServicesWithRunningStatus = useMemo(() => {
return filteredServices.filter(
@@ -776,6 +883,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"}
@@ -1210,6 +1322,9 @@ const EnvironmentPage = (
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="lastDeploy-desc">
Recently deployed
</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
@@ -1284,6 +1399,39 @@ const EnvironmentPage = (
</Command>
</PopoverContent>
</Popover>
{(availableServers.length > 0 ||
hasServicesWithoutServer) && (
<Select
value={selectedServerId || "all"}
onValueChange={setSelectedServerId}
>
<SelectTrigger className="lg:w-[200px]">
<SelectValue placeholder="Filter by server..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All servers</SelectItem>
{hasServicesWithoutServer && (
<SelectItem value="dokploy-server">
<div className="flex items-center gap-2">
<ServerIcon className="size-4" />
<span>Dokploy server</span>
</div>
</SelectItem>
)}
{availableServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<div className="flex items-center gap-2">
<ServerIcon className="size-4" />
<span>{server.serverName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
@@ -1389,7 +1537,15 @@ const EnvironmentPage = (
</CardTitle>
</CardHeader>
<CardFooter className="mt-auto">
<div className="space-y-1 text-sm">
<div className="space-y-1 text-sm w-full">
{service.serverName && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ServerIcon className="size-3" />
<span className="truncate">
{service.serverName}
</span>
</div>
)}
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>

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

@@ -1,6 +1,6 @@
import { findAdmin } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { users_temp } from "@dokploy/server/db/schema";
import { user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => {
@@ -8,11 +8,11 @@ import { eq } from "drizzle-orm";
const result = await findAdmin();
const update = await db
.update(users_temp)
.update(user)
.set({
twoFactorEnabled: false,
})
.where(eq(users_temp.id, result.userId));
.where(eq(user.id, result.userId));
if (update) {
console.log("2FA reset successful");

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

@@ -3,13 +3,14 @@ import {
addNewService,
checkServiceAccess,
cloneCompose,
cloneComposeRemote,
createCommand,
createCompose,
createComposeByTemplate,
createDomain,
createMount,
deleteMount,
execAsync,
execAsyncRemote,
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
@@ -245,6 +246,7 @@ export const composeRouter = createTRPCRouter({
});
}
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
loadServices: protectedProcedure
@@ -301,10 +303,12 @@ export const composeRouter = createTRPCRouter({
message: "You are not authorized to fetch this compose",
});
}
const command = await cloneCompose(compose);
if (compose.serverId) {
await cloneComposeRemote(compose);
await execAsyncRemote(compose.serverId, command);
} else {
await cloneCompose(compose);
await execAsync(command);
}
return compose.sourceType;
} catch (err) {
@@ -405,6 +409,7 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Deployment queued" };
}),
redeploy: protectedProcedure
.input(apiRedeployCompose)
@@ -440,6 +445,7 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Redeployment queued" };
}),
stop: protectedProcedure
.input(apiFindCompose)

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

@@ -2,6 +2,7 @@ import {
createDiscordNotification,
createEmailNotification,
createGotifyNotification,
createLarkNotification,
createNtfyNotification,
createSlackNotification,
createTelegramNotification,
@@ -11,6 +12,7 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendServerThresholdNotifications,
sendSlackNotification,
@@ -18,6 +20,7 @@ import {
updateDiscordNotification,
updateEmailNotification,
updateGotifyNotification,
updateLarkNotification,
updateNtfyNotification,
updateSlackNotification,
updateTelegramNotification,
@@ -36,6 +39,7 @@ import {
apiCreateDiscord,
apiCreateEmail,
apiCreateGotify,
apiCreateLark,
apiCreateNtfy,
apiCreateSlack,
apiCreateTelegram,
@@ -43,18 +47,20 @@ import {
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestGotifyConnection,
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateGotify,
apiUpdateLark,
apiUpdateNtfy,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
server,
users_temp,
user,
} from "@/server/db/schema";
export const notificationRouter = createTRPCRouter({
@@ -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),
@@ -352,9 +359,9 @@ export const notificationRouter = createTRPCRouter({
if (input.ServerType === "Dokploy") {
const result = await db
.select()
.from(users_temp)
.from(user)
.where(
sql`${users_temp.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.id) {
@@ -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

@@ -41,6 +41,11 @@ export const organizationRouter = createTRPCRouter({
});
}
// Check if this is the user's first organization
const existingMemberships = await db.query.member.findMany({
where: eq(member.userId, ctx.user.id),
});
await db.insert(member).values({
organizationId: result.id,
role: "owner",
@@ -63,6 +68,11 @@ export const organizationRouter = createTRPCRouter({
),
),
),
with: {
members: {
where: eq(member.userId, ctx.user.id),
},
},
});
return memberResult;
}),
@@ -184,4 +194,45 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
setDefault: protectedProcedure
.input(
z.object({
organizationId: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// First, unset all defaults for this user
await db
.update(member)
.set({ isDefault: false })
.where(eq(member.userId, ctx.user.id));
// Then set this organization as default
await db
.update(member)
.set({ isDefault: true })
.where(
and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
);
return { success: true };
}),
});

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

@@ -383,6 +383,15 @@ export const serverRouter = createTRPCRouter({
const ip = await getPublicIpWithFallback();
return ip;
}),
getServerTime: protectedProcedure.query(() => {
if (IS_CLOUD) {
return null;
}
return {
time: new Date(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}),
getServerMetrics: protectedProcedure
.input(
z.object({

View File

@@ -4,6 +4,7 @@ import {
findNotificationById,
findOrganizationById,
findUserById,
getDokployUrl,
getUserByToken,
IS_CLOUD,
removeUserById,
@@ -419,11 +420,10 @@ export const userRouter = createTRPCRouter({
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
: await getDokployUrl();
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(

View File

@@ -2,13 +2,8 @@ import {
deployApplication,
deployCompose,
deployPreviewApplication,
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -24,91 +19,48 @@ export const deploymentWorker = new Worker(
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.server) {
if (job.data.type === "redeploy") {
await rebuildRemoteApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployRemoteApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else {
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.server) {
if (job.data.type === "redeploy") {
await rebuildRemoteCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployRemoteCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else {
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (job.data.server) {
if (job.data.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
} else {
if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {

View File

@@ -2,6 +2,7 @@ import type http from "node:http";
import {
docker,
execAsync,
getHostSystemStats,
getLastAdvancedStatsFile,
recordAdvancedStats,
validateRequest,
@@ -49,6 +50,21 @@ export const setupDockerStatsMonitoringSocketServer = (
}
const intervalId = setInterval(async () => {
try {
// Special case: when monitoring "dokploy", get host system stats instead of container stats
if (appName === "dokploy") {
const stat = await getHostSystemStats();
await recordAdvancedStats(stat, appName);
const data = await getLastAdvancedStatsFile(appName);
ws.send(
JSON.stringify({
data,
}),
);
return;
}
const filter = {
status: ["running"],
...(appType === "application" && {

View File

@@ -22,7 +22,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync("docker pull traefik:v3.5.0");
await execAsync("docker pull traefik:v3.6.1");
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();

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

@@ -6,7 +6,7 @@
// boolean,
// } from "drizzle-orm/pg-core";
// export const users_temp = pgTable("users_temp", {
// export const user = pgTable("user", {
// id: text("id").primaryKey(),
// name: text("name").notNull(),
// email: text("email").notNull().unique(),
@@ -29,7 +29,7 @@
// userAgent: text("user_agent"),
// userId: text("user_id")
// .notNull()
// .references(() => users_temp.id, { onDelete: "cascade" }),
// .references(() => user.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// });
@@ -39,7 +39,7 @@
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => users_temp.id, { onDelete: "cascade" }),
// .references(() => user.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),

View File

@@ -61,7 +61,7 @@
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"node-os-utils": "1.3.7",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
@@ -88,7 +88,6 @@
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",

View File

@@ -9,7 +9,7 @@ import {
import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { users_temp } from "./user";
import { user } from "./user";
export const account = pgTable("account", {
id: text("id")
@@ -21,7 +21,7 @@ export const account = pgTable("account", {
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
@@ -39,9 +39,9 @@ export const account = pgTable("account", {
});
export const accountRelations = relations(account, ({ one }) => ({
user: one(users_temp, {
user: one(user, {
fields: [account.userId],
references: [users_temp.id],
references: [user.id],
}),
}));
@@ -65,15 +65,15 @@ export const organization = pgTable("organization", {
metadata: text("metadata"),
ownerId: text("owner_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
});
export const organizationRelations = relations(
organization,
({ one, many }) => ({
owner: one(users_temp, {
owner: one(user, {
fields: [organization.ownerId],
references: [users_temp.id],
references: [user.id],
}),
servers: many(server),
projects: many(projects),
@@ -90,10 +90,11 @@ export const member = pgTable("member", {
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
isDefault: boolean("is_default").notNull().default(false),
// Permissions
canCreateProjects: boolean("canCreateProjects").notNull().default(false),
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),
@@ -108,6 +109,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()
@@ -127,9 +134,9 @@ export const memberRelations = relations(member, ({ one }) => ({
fields: [member.organizationId],
references: [organization.id],
}),
user: one(users_temp, {
user: one(user, {
fields: [member.userId],
references: [users_temp.id],
references: [user.id],
}),
}));
@@ -144,7 +151,7 @@ export const invitation = pgTable("invitation", {
expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
teamId: text("team_id"),
});
@@ -161,7 +168,7 @@ export const twoFactor = pgTable("two_factor", {
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
});
export const apikey = pgTable("apikey", {
@@ -172,7 +179,7 @@ export const apikey = pgTable("apikey", {
key: text("key").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
@@ -191,8 +198,8 @@ export const apikey = pgTable("apikey", {
});
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(users_temp, {
user: one(user, {
fields: [apikey.userId],
references: [users_temp.id],
references: [user.id],
}),
}));

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";
@@ -28,6 +28,8 @@ import { server } from "./server";
import {
applicationStatus,
certificateType,
type EndpointSpecSwarm,
EndpointSpecSwarmSchema,
type HealthCheckSwarm,
HealthCheckSwarmSchema,
type LabelsSwarm,
@@ -80,6 +82,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 +102,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 +168,8 @@ 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" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
//
replicas: integer("replicas").default(1).notNull(),
applicationStatus: applicationStatus("applicationStatus")
@@ -252,6 +258,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 +310,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 +320,8 @@ const createSchema = createInsertSchema(applications, {
watchPaths: z.array(z.string()).optional(),
previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
});
export const apiCreateApplication = createSchema.pick({
@@ -456,6 +466,7 @@ export const apiSaveEnvironmentVariables = createSchema
applicationId: true,
env: true,
buildArgs: true,
buildSecrets: true,
})
.required();

View File

@@ -19,7 +19,7 @@ import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { user } from "./user";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@@ -74,7 +74,7 @@ export const backups = pgTable("backup", {
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
onDelete: "cascade",
}),
userId: text("userId").references(() => users_temp.id),
userId: text("userId").references(() => user.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
| {
@@ -118,9 +118,9 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
fields: [backups.mongoId],
references: [mongo.mongoId],
}),
user: one(users_temp, {
user: one(user, {
fields: [backups.userId],
references: [users_temp.id],
references: [user.id],
}),
compose: one(compose, {
fields: [backups.composeId],

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