Compare commits

...

350 Commits

Author SHA1 Message Date
Mauricio Siu
96dff0c1bb chore: bump version to v0.25.8 in package.json 2025-11-19 02:34:05 -06:00
Mauricio Siu
f53e1a6543 Merge pull request #3030 from AlexTMjugador/fix/compose-domains
fix: ensure Compose Traefik domain labels are written to local daemons
2025-11-19 02:33:46 -06:00
Mauricio Siu
9e2788e764 Merge pull request #3052 from Dokploy/360-request-for-adding-the-functionality-to-terminate-container-startup-process
feat: add KillBuild component and API mutation for terminating Docker…
2025-11-19 00:26:02 -06:00
Mauricio Siu
4884ee3352 feat: add KillBuild component and API mutation for terminating Docker builds
- Introduced a new KillBuild component that allows users to terminate ongoing Docker builds for both applications and compose setups.
- Implemented corresponding API mutations in the application and compose routers to handle build termination requests.
- Enhanced queue setup with a killDockerBuild function to execute the termination commands on the server.
2025-11-19 00:22:29 -06:00
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
Alejandro González
3a17c9b9e8 fix: ensure Compose Traefik domain labels are written to local daemons 2025-11-16 15:57:34 +01: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
Mauricio Siu
9f5c2dbe92 chore: update version to v0.25.4 in package.json 2025-09-28 22:32:35 -06:00
Mauricio Siu
0f9505327f Merge pull request #2710 from SimonLoir/canary
fix: add environment in buildLink for docker compose deploy notifications
2025-09-27 15:14:48 -06:00
Simon Loir
dd2902a57c fix: fix buildLink in docker compose deploy notifications 2025-09-27 16:50:25 +02:00
Mauricio Siu
0138a7c011 Merge pull request #2532 from monntterro/feat/gitea-http-support
feat: support cloning repositories over HTTP in Gitea integration
2025-09-27 03:17:08 -06:00
autofix-ci[bot]
845d2a3ac5 [autofix.ci] apply automated fixes 2025-09-27 09:15:31 +00:00
Mauricio Siu
4033bb84b2 Merge pull request #2640 from amirparsadd/patch-1
feat: support Arvancloud CDN detection
2025-09-27 03:14:12 -06:00
Mauricio Siu
43e96edcdd Merge pull request #2668 from alsmadi99/canary
feat(scheduler): auto-switch to 'Custom' on manual input
2025-09-27 03:13:00 -06:00
Mauricio Siu
2db388536f Merge pull request #2700 from dennisimoo/compose-alert
feat: add unsaved changes tracking and UI indication
2025-09-27 03:09:33 -06:00
Mauricio Siu
43876efc79 Merge pull request #2677 from dennisimoo/fix-position
style: move Deployments tab after Domains tab
2025-09-27 03:07:02 -06:00
Mauricio Siu
e7c7545c02 Merge pull request #2706 from Dokploy/2673-bitbucket-deployments-are-broken-auth-token-wont-work
fix(bitbucket): enhance Bitbucket authentication handling
2025-09-27 02:58:49 -06:00
autofix-ci[bot]
77705381cd [autofix.ci] apply automated fixes 2025-09-27 08:56:28 +00:00
Mauricio Siu
5fdf82a27f refactor(bitbucket): remove debug console logs from repository cloning process
- Removed console logs for clone URL and repository information to clean up the output during the cloning process.
2025-09-27 02:55:42 -06:00
Mauricio Siu
6bd5b1f71f fix(bitbucket): enhance Bitbucket authentication handling
- Added support for Bitbucket email and workspace name in the authentication process.
- Updated the clone URL generation to use the correct format for API tokens.
- Improved error handling to ensure required fields are provided for both API tokens and app passwords.
- Added console logs for debugging clone URL and repository information during cloning.
2025-09-27 02:55:06 -06:00
Mauricio Siu
17d6830b66 Merge pull request #2705 from Dokploy/2670-bug-deployments-are-mark-as-running-when-they-never-ended-vps-shutdown
2670 bug deployments are mark as running when they never ended vps shutdown
2025-09-27 02:23:53 -06:00
Mauricio Siu
a845eba320 Merge pull request #2696 from Harikrishnan1367709/Most-services-has-no-effect-#2691
Feat: "Most services" sorting to count total services across environments -2691
2025-09-27 02:22:58 -06:00
Mauricio Siu
2f4ec9f35f fix(deployment): reintroduce deployment cancellation during server initialization
- Added the call to initCancelDeployments back into the server initialization process to ensure that deployment cancellations are handled correctly in all environments.
2025-09-27 02:21:02 -06:00
autofix-ci[bot]
b725861b55 [autofix.ci] apply automated fixes 2025-09-27 08:20:36 +00:00
Mauricio Siu
6fa8f63277 fix(deployment): correct deployment cancellation logic and ensure proper status update
- Updated the initCancelDeployments function to set the status of running deployments to 'cancelled' instead of 'error'.
- Reintroduced the call to initCancelDeployments in the server initialization process to ensure cancellations are handled correctly.
2025-09-27 02:20:07 -06:00
Mauricio Siu
ac6bdf60ec feat(deployment): add 'cancelled' status to deployment and implement cancellation logic
- Updated the deployment status enum to include 'cancelled'.
- Added a new utility function to handle the cancellation of deployments, setting their status to 'error'.
- Enhanced the status tooltip component to display 'Cancelled' when the status is 'cancelled'.
- Created a new SQL migration to add the 'cancelled' value to the deploymentStatus type.
2025-09-27 02:15:43 -06:00
randomperson12344
db292e6949 feat: add unsaved changes tracking and UI indication 2025-09-26 20:13:09 -07:00
montero
085f6bbbb7 refactor(gitea): extract clone URL construction into a reusable function 2025-09-26 22:01:54 +03:00
autofix-ci[bot]
cbdc4e4a20 [autofix.ci] apply automated fixes 2025-09-26 08:48:23 +00:00
HarikrishnanD
ee3ff18feb fix: correct "Most services" sorting to count total services across environments - Fix sorting logic to count actual services instead of environment count - Projects now properly sort by total service count in descending order - Resolves issue where "Most services" showed ascending order instead of descending -#2691 2025-09-26 14:15:58 +05:30
autofix-ci[bot]
598ecb8c6e [autofix.ci] apply automated fixes 2025-09-25 06:39:08 +00:00
Sofien Scholze
1d5a523b9e fix: docker terminal dropdown not showing containers for application of type "docker-compose" 2025-09-24 22:52:20 +02:00
Lucas Manchine
4bced9ede0 fix: db migrations for stop grace period swarm 2025-09-24 12:00:09 -03:00
Lucas Manchine
e35aeef4e2 fix: db migrations for stop grace period swarm 2025-09-24 11:53:02 -03:00
Lucas Manchine
5e89ffbf4f fix: extend-database-schemas-with-stopgraceperiodswarm 2025-09-24 10:50:04 -03:00
Lucas Manchine
21de6bf167 test: add missing test 2025-09-24 10:26:36 -03:00
Lucas Manchine
291edce62f fixing migration 2025-09-24 10:02:15 -03:00
Lucas Manchine
59be1c5941 fix: coerce-stopgraceperiodswarm-to-number 2025-09-24 09:54:54 -03:00
Lucas Manchine
2141e4b174 Merge branch 'canary' into feature/stop-grace-period-2227 2025-09-24 08:52:32 -03:00
randomperson12344
df0fb340ad feat: add file upload support for custom profile pictures 2025-09-23 22:32:32 -07:00
randomperson12344
190ccfa91f style: replace generic icons with Gotify and Ntfy brand logos 2025-09-23 21:04:55 -07:00
randomperson12344
f5084dd5fb feat(ui): move Deployments tab to position 4 after Domains tab 2025-09-23 19:23:43 -07:00
autofix-ci[bot]
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]
1b603d84d7 [autofix.ci] apply automated fixes 2025-09-22 19:11:08 +00:00
Mohammad Alsmadi
cf2c89d136 feat(scheduler): auto-switch to 'Custom' on manual input 2025-09-22 13:35:52 +04:00
Amirparsa Baghdadi
95de98e94d close string 2025-09-22 12:37:21 +03:30
Mauricio Siu
569d43ae7f Merge pull request #2525 from divaltor/bitbucket-api-token
feat(bitbucket): Deprecate App password and replace it with API token
2025-09-21 15:18:40 -06:00
Mauricio Siu
d22ed9b569 refactor(bitbucket): streamline extractCommitedPaths function by passing Bitbucket object directly 2025-09-21 15:15:21 -06:00
Mauricio Siu
8b88c85b37 refactor(bitbucket): simplify extractCommitedPaths function by using Bitbucket type and centralized header generation 2025-09-21 15:14:15 -06:00
Mauricio Siu
11fbd047d0 feat(bitbucket): enhance API token creation instructions in Bitbucket provider settings 2025-09-21 14:13:55 -06:00
Mauricio Siu
69af9c0312 refactor(gitea): update repository and branch fetching to use pagination with /user/repos and /branches endpoints 2025-09-21 14:10:00 -06:00
Mauricio Siu
063d51e442 feat(bitbucket): add bitbucketEmail field to Bitbucket provider settings and update related API and database schema 2025-09-21 13:54:53 -06:00
Mauricio Siu
0a789e1d6f feat(bitbucket): add apiToken column to the Bitbucket table and update migration journal with new entry for 0111_mushy_wolfsbane 2025-09-21 03:10:52 -06:00
Mauricio Siu
671cd497fd Merge branch 'canary' into bitbucket-api-token 2025-09-21 03:10:37 -06:00
Mauricio Siu
8ddc254252 chore: remove '0110_smiling_slapstick' migration and associated journal entry 2025-09-21 03:10:30 -06:00
Mauricio Siu
2668e22302 feat(bitbucket): add apiToken column to Bitbucket table and update migration journal 2025-09-21 03:09:32 -06:00
Mauricio Siu
37145fbdf2 chore: bump version to v0.25.3 in package.json 2025-09-21 03:02:43 -06:00
Mauricio Siu
6847d8dbef Merge pull request #2516 from cheetahbyte/fix/special-characters-passwords
fix(registries): special character passwords not working in registry creation.
2025-09-21 02:45:43 -06:00
Mauricio Siu
032bcb7459 Merge pull request #2657 from Dokploy/2529-renaming-a-git-provider-wont-update-the-external-link-url
feat: add appName field to GitHub provider settings and update relate…
2025-09-21 02:41:11 -06:00
Mauricio Siu
68be7a259f Merge pull request #2656 from Dokploy/2533-unknown-tag-reset-error-in-domains-when-extending-docker-compose-configuration
refactor: replace js-yaml with yaml package for YAML parsing and stri…
2025-09-21 02:40:40 -06:00
Mauricio Siu
7d682870ff feat: add appName field to GitHub provider settings and update related API and database schema 2025-09-21 02:39:20 -06:00
Mauricio Siu
d1a1a80c77 refactor: further standardize YAML parsing in test files by replacing load with parse 2025-09-21 02:38:16 -06:00
Mauricio Siu
3d7dc82232 refactor: update test files to consistently use parse function for YAML parsing 2025-09-21 02:35:36 -06:00
Mauricio Siu
fedc88eb40 refactor: consistently replace load function with parse for YAML parsing in all test files 2025-09-21 02:28:30 -06:00
Mauricio Siu
5d0f6a4657 refactor: replace load function with parse for YAML parsing in test files 2025-09-21 02:27:16 -06:00
Mauricio Siu
4718461405 refactor: update YAML parsing from js-yaml to yaml package in test files 2025-09-21 02:24:41 -06:00
Mauricio Siu
80b22d9458 refactor: replace js-yaml with yaml package for YAML parsing and stringifying across the application 2025-09-21 02:20:20 -06:00
Mauricio Siu
8fa5fe7f2c Merge pull request #2654 from Dokploy/2018-traefik-never-start-error-read-etctraefiktraefikyml-is-a-directory
2018 traefik never start error read etctraefiktraefikyml is a directory
2025-09-21 01:39:06 -06:00
Mauricio Siu
4ced8bec96 feat: add completion message and exit process after Dokploy setup 2025-09-21 01:35:46 -06:00
Mauricio Siu
9ecb770a01 fix: enhance Traefik setup by adding directory checks and cleanup for existing config files 2025-09-21 01:31:21 -06:00
Mauricio Siu
8ac586b2f7 Merge pull request #2653 from Dokploy/2554-ai-assistant-is-broken-in-v025
fix: handle optional configFiles in template details and improve mapp…
2025-09-21 01:11:27 -06:00
Mauricio Siu
0a1800ba6d fix: adjust layout by removing unnecessary flex class from password input container 2025-09-21 01:09:12 -06:00
Mauricio Siu
f13028ee70 fix: handle optional configFiles in template details and improve mapping safety 2025-09-21 01:07:25 -06:00
Mauricio Siu
b6b6b9f2ce Merge pull request #2652 from Dokploy/2630-backups-dont-get-deleted-when-backup-fails
fix: enhance error handling in volume backup process by adding cleanu…
2025-09-21 00:27:45 -06:00
Mauricio Siu
f46637b8e1 fix: enhance error handling in volume backup process by adding cleanup for .tar files 2025-09-21 00:26:48 -06:00
Mauricio Siu
948ed2cc0d fix: improve registry tag construction to conditionally include registry URL 2025-09-21 00:13:56 -06:00
Mauricio Siu
a536c977f0 Merge pull request #2651 from Dokploy/2633-error-parsing-reference-app-aaa-9bkzznlatest-is-not-a-valid-repositorytag-invalid-reference-format
fix: update registry tag construction to handle optional registry URL
2025-09-21 00:11:21 -06:00
Mauricio Siu
8524cd0972 fix: update registry tag construction to handle optional registry URL 2025-09-21 00:09:19 -06:00
Mauricio Siu
ac1e51cd11 Merge pull request #2650 from Dokploy/2638-overlay-network-not-working-across-nodes
refactor: replace getPublicIpWithFallback with getLocalServerIp for i…
2025-09-21 00:01:28 -06:00
Mauricio Siu
ca243d7259 refactor: replace getPublicIpWithFallback with getLocalServerIp for improved local IP retrieval 2025-09-20 23:57:38 -06:00
Mauricio Siu
e1ce54c159 Merge pull request #2622 from Harikrishnan1367709/Compose-does-not-display-the-domain-under-dashboard/projects-#2606
fix: Display Compose service domains in projects dashboard (#2606)
2025-09-20 16:37:49 -06:00
Mauricio Siu
031302d808 Merge pull request #2643 from nimone/patch-1
fix: prevent the shrinking of icon button for view mode on add template dialog
2025-09-20 16:36:57 -06:00
Mauricio Siu
5e01505e4d fix: update input class for better responsiveness in add template component 2025-09-20 16:36:36 -06:00
Mauricio Siu
c423724972 Merge pull request #2614 from Harikrishnan1367709/Profile-email-field-accepts-empty-values-causing-sign-in-issues-#2613
Fix profile email validation to prevent empty values causing sign-in issues-#2613
2025-09-20 16:34:20 -06:00
Mauricio Siu
f1f7639708 Merge pull request #2624 from dragospaulpop/dragospaulpop-patch-cloneRawGitlabRepositoryRemote
Fix: Update gitlab.ts cloneRawGitlabRepositoryRemote to use gitlabBranch
2025-09-20 16:32:19 -06:00
Mauricio Siu
9ef1a76a85 Merge pull request #2582 from yigitahmetsahin/feat/improve-db-backups
feat(backups): make mariadb backups non-blocking
2025-09-20 16:31:16 -06:00
Nishant Mogha
30b66a4828 fix: prevent shrinking icon button for view mode on add template 2025-09-19 21:13:20 +05:30
Amirparsa Baghdadi
4416ca9cd2 Add arvancloud to CDNs 2025-09-19 15:58:22 +03:30
Dragos-Paul Pop
f2ead66890 Update gitlab.ts cloneRawGitlabRepositoryRemote to use gitlabBranch
Cloning a GitLab repository for a compose service to a remote server incorrectly used the "branch" column from Postgres' "compose" table instead of the "gitlabBranch" column causing an error.
2025-09-17 11:48:12 +03:00
HarikrishnanD
64475bbb13 fix: Compose domain display logic in projects dashboard - Uncommented the commented-out Compose domain rendering code in ShowProjects.tsx - Fixed data structure to properly iterate through project.environments and env.compose - Added proper condition checking for compose services - Compose services now display their domains in the projects dashboard dropdown - Resolves issue #2606 where template-deployed Compose services didn't show domains 2025-09-17 14:07:03 +05:30
autofix-ci[bot]
c1896f8877 [autofix.ci] apply automated fixes 2025-09-16 07:47:55 +00:00
HarikrishnanD
d13975adac fix: add email validation to profile form to prevent empty values - Add email format and required validation to profile form schema - Add email validation to API schema and service layer - Improve error handling in user update mutation - Fixes issue where users could save empty email causing sign-in failures -#2613 2025-09-16 13:11:22 +05:30
Mauricio Siu
b8e9602538 feat(bitbucket): update Bitbucket token management and add API token column to database 2025-09-15 23:10:50 -06:00
Mauricio Siu
afca968853 chore: remove unused migration and associated journal entry for '0110_dry_golden_guardian' 2025-09-15 23:03:43 -06:00
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
Yigit SAHIN
c9715b19a3 feat(backups): make mariadb backups non-blocking closes #2443 2025-09-10 11:27:22 +03:00
Rob Graeber
1a940580ae Fix swarm settings config placeholders 2025-09-09 18:03:02 -06:00
Lucas Manchine
b7e2df6d6a refactor: clean up stopGracePeriodSwarm assignment formatting │
│                                                                                                                                                                                                                                                                                                                                                                                                    │
│   - Improve code readability by condensing multi-line assignment                                                                                                                                                                                                                                                                                                                                   │
│   - Maintain consistent formatting with other field assignments                                                                                                                                                                                                                                                                                                                                    │
│   - No functional changes, formatting only
2025-09-05 15:34:03 -03:00
Lucas Manchine
85e3a92877 feat(swarm): add stop grace period configuration for Docker Swarm services
- Add stopGracePeriodSwarm field to application schema for configuring container shutdown grace period
- Update swarm settings UI to include nanosecond input for stop grace period
- Regenerate migration as 0110 to resolve sequence conflict with canary branch
- Clean up commented debug code and reorganize imports

The stop grace period allows users to specify how long Docker should wait before forcefully
terminating a container during shutdown, improving graceful shutdown handling for applications.
2025-09-05 13:23:46 -03:00
Lucas Manchine
c2eaa78724 refactor: clean up stopGracePeriodSwarm implementation │
│                                                                                                                                                                                                                                                                                                                               │
│   - Remove commented debug code                                                                                                                                                                                                                                                                                               │
│   - Reorganize imports for better readability
2025-09-05 13:05:46 -03:00
Lucas Manchine
270b4d4edc Merge branch 'canary' into feature/stop-grace-period-2227-alt 2025-09-05 12:34:17 -03:00
Vlad Vladov
a6ca41f91f feat(bitbucket): Re-generate migration 2025-09-05 18:09:18 +03:00
Vlad Vladov
b2b649c5cd refactor(bitbucket): Extract duplicated code to a function 2025-09-05 18:08:37 +03:00
autofix-ci[bot]
225c398d31 [autofix.ci] apply automated fixes 2025-09-05 18:08:37 +03:00
Vlad Vladov
07b99bd4e4 style(ui): Remove tooltip 2025-09-05 18:08:37 +03:00
autofix-ci[bot]
652e8910f4 [autofix.ci] apply automated fixes 2025-09-05 18:08:36 +03:00
Vlad Vladov
e04e25385d feat(bitbucket): Deprecate App password and replace it with API token 2025-09-05 18:08:35 +03:00
Lucas Manchine
da9df3e239 testing changes 2025-09-05 11:49:32 -03:00
autofix-ci[bot]
68945c6888 [autofix.ci] apply automated fixes 2025-09-03 18:17:04 +00:00
Leonhard Breuer
146d82b6c4 feat: use printf instead of echo 2025-09-03 20:12:16 +02:00
Leonhard Breuer
02215d4e21 fix: use new command for registry updates 2025-09-03 19:59:17 +02:00
Leonhard Breuer
4ca05414af fix: use shellsafe docker command
- add `shEscape` function - add `safeDockerLoginCommand` - use the new
functions to contruct better registry login command
2025-09-03 19:52:01 +02:00
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
225 changed files with 72858 additions and 4246 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,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = load(`
const expectedComposeFile1 = parse(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

@@ -0,0 +1,65 @@
import { Scissors } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Kill Build
<Scissors className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
<AlertDialogDescription>
This will kill the build process
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(() => {
toast.success("Build killed successfully");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

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";
@@ -17,6 +25,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -80,6 +89,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(() => {
@@ -118,6 +144,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
@@ -217,118 +246,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

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

View File

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

@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
DatabaseZap,
Info,
PenBoxIcon,
PlusCircle,
RefreshCw,
} from "lucide-react";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,13 +41,19 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { commonCronExpressions } from "../schedules/handle-schedules";
import { ScheduleFormField } from "../schedules/handle-schedules";
const formSchema = z
.object({
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()
@@ -306,64 +306,9 @@ export const HandleVolumeBackups = ({
</FormItem>
)}
/>
<FormField
control={form.control}
<ScheduleFormField
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
formControl={form.control}
/>
<FormField

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

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

View File

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

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

View File

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

@@ -171,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
className="w-full sm:w-[200px]"
className="w-full"
value={query}
/>
<Input
@@ -248,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
}
className="h-9 w-9"
className="h-9 w-9 flex-shrink-0"
>
{viewMode === "detailed" ? (
<LayoutGrid className="size-4" />

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

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

View File

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

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles: Mount[];
configFiles?: Mount[];
}
interface Mount {

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();
@@ -96,8 +98,30 @@ export const ShowProjects = () => {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "services": {
const aTotalServices = a.environments.length;
const bTotalServices = b.environments.length;
const aTotalServices = a.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
const bTotalServices = b.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
comparison = aTotalServices - bTotalServices;
break;
}
@@ -113,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 ">
@@ -126,7 +155,6 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className="">
<HandleProject />
@@ -276,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}
@@ -291,45 +325,54 @@ export const ShowProjects = () => {
)}
</DropdownMenuGroup>
)}
{/*
{project.compose.length > 0 && (
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)} */}
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}

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

View File

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

View File

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

View File

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

View File

@@ -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,11 +29,14 @@ 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({
email: z.string(),
email: z
.string()
.email("Please enter a valid email address")
.min(1, "Email is required"),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
@@ -59,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();
@@ -117,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 (
@@ -155,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">
@@ -254,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">
@@ -276,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">
@@ -286,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

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

View File

@@ -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 @@
ALTER TABLE "bitbucket" ADD COLUMN "apiToken" text;

View File

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

View File

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

View File

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

View File

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

View File

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

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