Compare commits

...

285 Commits

Author SHA1 Message Date
Mauricio Siu
bd4964f70f feat(user): implement organization membership checks for API key creation and user organization queries
- Added verification to ensure users are members of the specified organization when creating API keys.
- Implemented checks to restrict organization queries to users who are either checking their own organizations or are admins/owners of the active organization.
- Enhanced error handling to return FORBIDDEN responses for unauthorized access attempts.
2025-12-08 00:11:24 -06:00
Mauricio Siu
07bf520e9b feat(organization): refine organization deletion logic with enhanced membership checks
- Added verification to ensure the user is a member of the organization before allowing deletion.
- Implemented checks to confirm the user is either the organization owner or has the owner role.
- Improved error handling to return a FORBIDDEN response if the user is not authorized to delete the organization.
2025-12-08 00:08:07 -06:00
Mauricio Siu
c42e859215 feat(organization): add user membership verification for organization queries
- Implemented a check to verify if the user is a member of the organization before allowing access to organization data.
- Added error handling to return a FORBIDDEN response if the user is not a member.
2025-12-08 00:05:55 -06:00
Mauricio Siu
e666cfb374 feat(organization): enhance organization update logic with member verification
- Added checks to ensure the organization exists before allowing updates.
- Implemented user membership verification to restrict updates to organization members only.
- Ensured that only the organization owner or users with the owner role can perform updates.
2025-12-08 00:02:27 -06:00
Mauricio Siu
1d9b9ff9b6 Merge pull request #3190 from Dokploy/2935-timezone-for-scheduled-tasks
feat(schedules): add timezone support for scheduled jobs and update d…
2025-12-07 22:44:56 -06:00
Mauricio Siu
6c61919202 feat(schedules): add timezone support for scheduled jobs and update database schema
- Introduced a new `commonTimezones` array for timezone selection in the UI.
- Updated the schedule form to include an optional timezone field.
- Modified the database schema to add a `timezone` column to the `schedule` table.
- Enhanced the scheduling logic to utilize the specified timezone, defaulting to UTC if not provided.
2025-12-07 22:42:40 -06:00
Mauricio Siu
a9a42d2066 Merge pull request #3121 from TonyStef/feat/project-search-url-params
feat: persist search query in URL parameters on projects page
2025-12-07 20:23:36 -06:00
Mauricio Siu
0f6ac310b5 refactor(dashboard): streamline search query handling and URL synchronization in project display 2025-12-07 20:21:02 -06:00
Mauricio Siu
c267faef08 Merge branch 'canary' into feat/project-search-url-params 2025-12-07 20:15:18 -06:00
Mauricio Siu
d2be0855c1 Merge pull request #3135 from usings/fix/remote-server-docker-version
fix: align `DOCKER_VERSION` with official installation script
2025-12-07 20:14:37 -06:00
Mauricio Siu
c9aaee149a Merge pull request #2717 from Omar125X/fix/domain-validation
fix: improve domain and letsencrypt email validation
2025-12-07 20:07:46 -06:00
Mauricio Siu
d435553839 chore(auth): remove debug log statement for user in authentication flow 2025-12-07 20:06:54 -06:00
Mauricio Siu
28f40066a2 fix(settings): simplify letsEncryptEmail assignment and ensure router rule is always updated with new host 2025-12-07 20:03:54 -06:00
Mauricio Siu
22e6a06426 Merge branch 'canary' into fix/domain-validation 2025-12-07 19:56:56 -06:00
Mauricio Siu
94faf78f16 Merge pull request #2758 from NeoIsRecursive/fix/match-multi-line-log-messages
fix: match multi line log messages
2025-12-07 19:47:51 -06:00
Mauricio Siu
c4351482fa Merge pull request #2699 from ChristoferMendes/feature/add-custom-webhook-notification-provider
feat(notifications): add custom webhook notification provider
2025-12-07 13:45:16 -06:00
Mauricio Siu
5412c5a873 feat: enhance custom notification handling and schema
- Updated the custom notification type to include an array of headers, allowing for more flexible header management.
- Removed the obsolete KeyValueInput component, streamlining the notification settings interface.
- Adjusted the database schema to support JSONB for headers in the custom table, improving data handling.
- Enhanced the notification testing functionality to accommodate the new headers structure.
- Updated related API endpoints and utility functions to reflect these changes.
2025-12-07 13:42:30 -06:00
autofix-ci[bot]
212006ba9e [autofix.ci] apply automated fixes 2025-12-07 19:32:00 +00:00
Mauricio Siu
18d12d1a6f Merge branch 'canary' into feature/add-custom-webhook-notification-provider 2025-12-07 13:23:59 -06:00
Mauricio Siu
5d5af8f57f chore: remove obsolete SQL files and journal entries for custom notification type
- Deleted SQL file related to the 'custom' notification type and its schema changes.
- Removed corresponding journal entry from the metadata to reflect the deletion of the custom notification type.
2025-12-07 13:16:22 -06:00
Mauricio Siu
7f8f97c48f feat: introduce custom notification type and related schema changes
- Added a new notification type 'custom' to the notificationType enum.
- Created a new 'custom' table with fields for customId, endpoint, and headers.
- Updated the notification table to include a customId column and established a foreign key relationship with the new custom table.
- Updated journal and snapshot files to reflect these changes.
2025-12-07 13:15:49 -06:00
Mauricio Siu
67865c5283 chore: update notification schema by removing customId references
- Removed the 'customId' field and its associated foreign key constraints from the notification schema in both 0113_snapshot.json and 0114_snapshot.json.
- Updated the list of supported notification types to exclude 'custom', reflecting the recent changes in notification handling.
2025-12-07 13:15:12 -06:00
Mauricio Siu
817264eae4 chore: remove obsolete SQL files for custom webhook notifications
- Deleted SQL scripts related to the 'custom' notification type and its associated table modifications, as they are no longer needed in the current schema.
2025-12-07 13:14:12 -06:00
Mauricio Siu
5360df7a53 Merge pull request #3188 from fir4tozden/fix/remove-volume-cleanup-from-cleanupall
fix: remove volume cleanup from cleanupAll()
2025-12-07 12:52:33 -06:00
фырат ёздэн
fec4daa59b fix: remove volume cleanup from cleanupAll() 2025-12-07 20:11:44 +03:00
фырат ёздэн
aae7906e77 chore: remove unused import 2025-12-07 20:10:26 +03:00
фырат ёздэн
86d14465cb fix: remove volume cleanup from cleanupAll() 2025-12-07 19:48:28 +03:00
Mauricio Siu
f84c659121 Merge pull request #3183 from Dokploy/feat/add-last-name-to-profile
feat(user): update user schema to include firstName and lastName fiel…
2025-12-07 04:39:23 -06:00
Mauricio Siu
89cb9c24c9 refactor(user): add firstName and lastName fields to baseAdmin user object for improved user data structure 2025-12-07 04:37:28 -06:00
Mauricio Siu
c7fcea7d6a refactor(auth): update auth client to enhance type inference for user fields in auth structure 2025-12-07 04:34:49 -06:00
Mauricio Siu
d4555e6985 refactor(auth): enhance type definitions for auth object to improve type safety and clarity 2025-12-07 04:31:04 -06:00
Mauricio Siu
daa54cea8d refactor(auth): update auth client to use new auth structure and improve type inference 2025-12-07 04:28:35 -06:00
Mauricio Siu
77aff700fd refactor(docker): remove Umami environment variables from Dockerfile and clean up related code in _app.tsx 2025-12-07 04:27:14 -06:00
Mauricio Siu
cdb0de9a72 feat(user): update user schema to include firstName and lastName fields, modify related components and forms for user registration and profile management 2025-12-07 04:26:24 -06:00
Mauricio Siu
a5353e5457 Merge pull request #2506 from divaltor/multiple-admins
feat(permissions): Add multiple admins capability
2025-12-07 03:11:23 -06:00
Mauricio Siu
a9b8beb50b refactor(settings): reorganize cleanup functions and update imports for better clarity 2025-12-07 03:02:01 -06:00
Mauricio Siu
6022f2f6a3 refactor(auth): replace findAdmin with findOwner in user management logic and update role-based permissions in the dashboard 2025-12-07 02:51:03 -06:00
Mauricio Siu
075e387bb6 refactor(users): remove member unlinking logic from ShowUsers component and update billing access check for owner role only 2025-12-07 02:38:34 -06:00
Mauricio Siu
568293ef3c feat(users): implement ChangeRole component for user role management in dashboard 2025-12-07 02:32:41 -06:00
Mauricio Siu
a9ae39dc94 feat(side-menu): update permissions to include admin role for Git provider access 2025-12-07 02:25:54 -06:00
Mauricio Siu
55a9640e31 Merge branch 'canary' into multiple-admins 2025-12-07 02:19:38 -06:00
Mauricio Siu
32d5959733 Merge pull request #2879 from Harikrishnan1367709/Volume-Backup-Notification-#2875
feat: Add Volume Backup Notification Support (#2875)
2025-12-07 02:18:10 -06:00
Mauricio Siu
bccd531457 fix(deployments): update layout for deployment actions to allow better wrapping of elements 2025-12-07 02:17:42 -06:00
Mauricio Siu
f5de5130f3 feat(volume-backup): add volume backup email notification template and integrate with notification system 2025-12-07 02:15:10 -06:00
Mauricio Siu
bd751658be Merge branch 'canary' into Volume-Backup-Notification-#2875 2025-12-07 02:04:00 -06:00
Mauricio Siu
9b23aa9c8c Merge pull request #3180 from Dokploy/2835-existing-env-file-is-overwritten-when-using-docker-builder
refactor(docker): remove unused environment file command generation a…
2025-12-06 19:07:32 -06:00
Mauricio Siu
dbc1396fa6 refactor(docker): remove unused environment file command generation and simplify Docker command construction 2025-12-06 19:06:53 -06:00
Mauricio Siu
4210eefd37 Merge pull request #3064 from fir4tozden/fix/docker-cleanup-clears-away-all-unused-residue
fix: docker cleanup clears away all unused residue
2025-12-06 17:57:28 -06:00
Mauricio Siu
91050ce3a5 feat(database): add SQL script to set default value for enableDockerCleanup column in user table 2025-12-06 17:55:26 -06:00
Mauricio Siu
9394d97163 refactor(settings): remove unused cleanupFullDocker function and streamline imports 2025-12-06 17:54:25 -06:00
Mauricio Siu
f91a3aab25 refactor(docker): improve code readability by adding braces for else statements in cleanup functions 2025-12-06 17:53:13 -06:00
Mauricio Siu
9fbd0dce9a Merge pull request #3178 from Dokploy/3081-ai-assistant-generates-irrelevant-variant-titles-eg-coolify-that-do-not-match-the-generated-compose-file
refactor(ai): enhance suggestion logic for deployment variants and op…
2025-12-06 17:39:19 -06:00
Mauricio Siu
9e405c0728 refactor(ai): enhance suggestion logic for deployment variants and open source project recommendations 2025-12-06 17:38:41 -06:00
Mauricio Siu
44892404c1 Merge pull request #3177 from Dokploy/3094-traefik-not-running-after-fresh-install---read-etctraefiktraefikyml-is-a-directory
fix(server): refactor initialization sequence to prevent race conditi…
2025-12-06 17:24:15 -06:00
Mauricio Siu
1362fdd4b4 fix(server): refactor initialization sequence to prevent race conditions in production environment 2025-12-06 16:41:31 -06:00
Mauricio Siu
c7c3b1018b Merge pull request #3176 from Dokploy/2974-tail-error-tail-inotify-cannot-be-used-reverting-to-polling-too-many-open-files
fix(wss): close read deployment and container logs connections
2025-12-06 15:22:03 -06:00
Mauricio Siu
0d9b72e00a fix(wss): enhance error handling and logging for SSH connections in WebSocket server 2025-12-06 15:20:51 -06:00
Mauricio Siu
80ed041420 fix(wss): improve error handling and logging in deployment logs WebSocket server 2025-12-06 15:16:07 -06:00
фырат ёздэн
ba9c2ef369 Merge branch 'canary' into fix/docker-cleanup-clears-away-all-unused-residue 2025-12-07 00:08:59 +03:00
Mauricio Siu
8bd4f403c4 Merge pull request #3175 from Dokploy/3125-bug-additional-port-mapping---ui-glitch-crash-on-used-port-release-notes-warning
fix(settings): prevent duplicate port entries by only adding the firs…
2025-12-06 14:26:20 -06:00
Mauricio Siu
7ea7ee739f fix(settings): prevent duplicate port entries by only adding the first mapping for each target port 2025-12-06 14:23:43 -06:00
Mauricio Siu
4873baa975 Merge pull request #2997 from Harikrishnan1367709/Traefik--Enable-dashboard-dokploy-traefik-container-gone,all-services-domains-down
fix(traefik): validate port 8080 before enabling dashboard -#2996
2025-12-06 14:08:57 -06:00
Mauricio Siu
287dfb5402 feat: add dialog action for enabling/disabling Traefik dashboard and enhance manage ports component with warning alert 2025-12-06 14:06:43 -06:00
Mauricio Siu
439fba1f4b fix: improve error handling for Traefik port updates and enhance port availability checks 2025-12-06 13:58:03 -06:00
Mauricio Siu
1ba24630a8 Merge branch 'canary' into Traefik--Enable-dashboard-dokploy-traefik-container-gone,all-services-domains-down 2025-12-06 13:09:06 -06:00
Mauricio Siu
de6c1a7981 Merge pull request #2972 from Harikrishnan1367709/Requests-Warning-Conditional-Render--#2971
feat(requests): conditionally render traefik reload warning
2025-12-06 12:56:21 -06:00
Mauricio Siu
7948721f5a Merge pull request #3157 from sammychinedu2ky/fix-postgres-volume-mismatch
fix: update mount path for PostgreSQL 18+ to use /var/lib/postgresql/{version}/docker
2025-12-06 12:52:47 -06:00
Mauricio Siu
99cb80757c Merge pull request #3152 from Dokploy/feat/improve-rollbacks
Feat/improve rollbacks
2025-12-06 12:47:45 -06:00
Mauricio Siu
7467ada3a9 feat: enhance rollback confirmation dialog with additional information about deployment process 2025-12-06 12:45:28 -06:00
autofix-ci[bot]
f0f2188652 [autofix.ci] apply automated fixes 2025-12-06 18:39:15 +00:00
Mauricio Siu
2c1dfe9377 refactor: update tsconfig.json formatting and enhance rollback settings component with registry link 2025-12-06 12:38:51 -06:00
Mauricio Siu
9e8efab909 Merge branch 'canary' into feat/improve-rollbacks 2025-12-06 12:35:21 -06:00
Mauricio Siu
35612b21a0 Merge pull request #3174 from Dokploy/3170-volume-backups-and-possibly-other-places-fail-to-quote-connection-parameters
fix: update S3 credentials formatting in backup utility
2025-12-06 12:25:15 -06:00
Mauricio Siu
7873af1c39 fix: update S3 credentials formatting in backup utility 2025-12-06 12:24:47 -06:00
Mauricio Siu
ade727e2ed Merge pull request #3173 from CatPaulKatze/fix/React2Shell
fix: React2Shell vulnerability in NextJS
2025-12-06 12:08:05 -06:00
Mauricio Siu
ac1fb6fb86 chore: remove eslint configuration from Next.js config 2025-12-06 12:05:26 -06:00
Mauricio Siu
b3168f75d0 chore: update Next.js version to 16.0.7 in pnpm-lock.yaml and package.json 2025-12-06 12:01:09 -06:00
Paul Sommer
0e7b550642 fix: React2Shell vulnerability in NextJS 2025-12-06 13:20:42 +01:00
Mauricio Siu
3e1030edda Bump version from v0.25.11 to v0.26.0 2025-12-05 14:49:09 -06:00
Mauricio Siu
3ea90de4e1 Merge pull request #3162 from philippgerard/fix/traefik-host-rule-label-regression-tests
test: add regression tests for Traefik Host rule format
2025-12-05 13:39:16 -06:00
autofix-ci[bot]
bccef0da4c [autofix.ci] apply automated fixes 2025-12-05 18:23:16 +00:00
Philipp C. Gérard
dc28ddba2a test: add regression tests for Traefik Host rule format
Add comprehensive tests to verify that Traefik Host rule labels are
generated with the correct format: Host(`domain.com`) with both
opening and closing parentheses.

These tests cover:
- Basic Host rule format validation
- PathPrefix format validation
- Combined Host and PathPrefix rules
- YAML serialization round-trip preservation
- Edge cases for various domain name formats
- Multiple entrypoints (web/websecure)
- Special characters in paths

Related to: #3161
2025-12-04 12:51:41 +01:00
Samson Amaugo
ed312dc1c0 fix: update mount path for PostgreSQL 18+ to use /var/lib/postgresql/{version}/docker 2025-12-04 02:21:57 +01:00
Mauricio Siu
6cafb15dbb Merge pull request #3078 from SteadEXE/patch-1
fix: parse CPU value for progress component in monitoring dashboard
2025-12-02 20:22:58 -06:00
Mauricio Siu
c34fdf7a46 fix: ensure proper parsing of CPU value for progress component in monitoring dashboard 2025-12-02 20:22:27 -06:00
autofix-ci[bot]
e627c9af99 [autofix.ci] apply automated fixes 2025-12-03 02:19:15 +00:00
Mauricio Siu
18e609313b refactor: simplify rollback error handling in rollback function
- Removed try-catch block in the rollback function to streamline error handling, allowing for direct propagation of errors from the rollbackApplication call.
- This change enhances code readability and maintains the functionality of the rollback process.
2025-12-02 00:56:27 -06:00
Mauricio Siu
fbf840bf6e test: update command generation tests to use mockResolvedValue
- Changed mockReturnValue to mockResolvedValue for getBuildCommand in deployApplication tests, ensuring asynchronous command generation is handled correctly.
- Added rollbackRegistryId, rollbackRegistry, and deployments fields to application settings in drop and traefik tests for improved rollback functionality.
2025-12-02 00:55:12 -06:00
Mauricio Siu
76613de095 fix: await build command in deployPreviewApplication function
- Updated the deployPreviewApplication function to await the getBuildCommand call, ensuring the command is fully constructed before execution.
- This change improves the reliability of the deployment process by handling asynchronous command generation correctly.
2025-12-02 00:49:40 -06:00
Mauricio Siu
5a7f55ea63 feat: implement rollback registry functionality in application settings
- Added a new optional field `rollbackRegistryId` to the application schema to support rollback registry selection.
- Enhanced the form in the ShowRollbackSettings component to include a dropdown for selecting a rollback registry when rollbacks are enabled.
- Updated the application service to handle rollback registry logic during deployment and rollback processes.
- Improved error handling and validation for rollback settings, ensuring a registry is selected when rollbacks are active.
- Adjusted database schema and migration files to accommodate the new rollback registry feature.
2025-12-02 00:48:11 -06:00
ChristoferMendes
be3403af0c Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-12-01 10:42:01 -03:00
fir4tozden
f190cc548c fix 2025-12-01 16:41:15 +03:00
fir4tozden
4df9b935a8 chore 2025-12-01 15:54:08 +03:00
autofix-ci[bot]
b9becbafd8 [autofix.ci] apply automated fixes 2025-12-01 11:58:23 +00:00
fir4tozden
60be376a4f chore: comments for services > settings.ts > cleanupFullDocker() 2025-12-01 14:58:02 +03:00
Jordan B
ef9732d5d9 refactor: change CPU value type from number to string 2025-12-01 08:51:55 +01:00
Mauricio Siu
e052850b87 Merge pull request #3148 from Dokploy/2938-not-all-ntfy-topics-need-access-tokens
feat: update notification handling to make accessToken optional
2025-12-01 00:47:03 -06:00
Mauricio Siu
e06f5979c3 refactor: streamline notification header construction in sendNtfyNotification
- Consolidated header creation for the notification request, improving code readability and maintainability.
- Made the Authorization header conditional based on the presence of accessToken, enhancing flexibility.
2025-12-01 00:46:35 -06:00
Mauricio Siu
6b346d30ee feat: update notification handling to make accessToken optional
- Modified the notification schema to allow accessToken to be optional, enhancing flexibility in notification settings.
- Updated related components and database schema to accommodate the change, ensuring backward compatibility.
- Improved handling of accessToken in notification requests and responses, defaulting to null when not provided.
2025-12-01 00:44:53 -06:00
Mauricio Siu
9e98f9ce7f Merge pull request #3147 from Dokploy/Add-search-functionality-to-AI-model-selection-dropdown
feat: enhance AI model selection with popover and search functionality
2025-11-30 23:47:20 -06:00
Mauricio Siu
c8e7aae5c6 feat: enhance AI model selection with popover and search functionality
- Replaced the select component with a popover for model selection, allowing for better user experience.
- Added search capability to filter models, improving accessibility and usability.
- Updated form handling to reset model selection when API URL or API Key changes.
- Ensured proper state management for popover visibility and search input.
2025-11-30 23:46:22 -06:00
Mauricio Siu
75a49790ea refactor: simplify useEffect dependencies in AddCommand and ShowCustomCommand components
- Updated the useEffect hooks to remove unnecessary dependencies, improving performance and readability.
- Ensured that the form resets correctly based on the presence of data.
2025-11-30 19:07:59 -06:00
Mauricio Siu
716e8b351f Merge pull request #2735 from VivekKavala/feat/top-loading-progress-bar
feat: Add top-loading progress bar
2025-11-30 18:49:18 -06:00
Mauricio Siu
e993955f5a Merge branch 'canary' into feat/top-loading-progress-bar 2025-11-30 18:47:54 -06:00
autofix-ci[bot]
d35307ead6 [autofix.ci] apply automated fixes 2025-11-30 22:12:01 +00:00
fir4tozden
c98db390dc chore: comments 2025-12-01 01:11:40 +03:00
autofix-ci[bot]
0c7265c9c9 [autofix.ci] apply automated fixes 2025-11-30 22:07:55 +00:00
fir4tozden
f1ef1d8489 fix: renames 2025-12-01 01:07:33 +03:00
fir4tozden
fbd095334c fix: renames 2025-12-01 00:58:02 +03:00
autofix-ci[bot]
e3832eff07 [autofix.ci] apply automated fixes 2025-11-30 21:50:25 +00:00
fir4tozden
25b7069e31 feat: docker cleanups stable 2025-12-01 00:50:02 +03:00
Mauricio Siu
caf0aa6a12 Merge pull request #3050 from andresousadotpt/fix/update-cdn-ip-ranges
fix(bunny.net): Update CDN IP ranges
2025-11-30 14:58:31 -06:00
Mauricio Siu
21eb185431 Merge pull request #3137 from Dokploy/feat/add-warning-to-redeploy-on-domain-changes
feat: add informational alert for domain changes in AddDomain component
2025-11-30 14:56:50 -06:00
фырат ёздэн
0fbb063d06 chore: updated dockerSafeExec() logs 2025-11-30 22:15:01 +03:00
Mauricio Siu
bb3f73851a Merge pull request #3035 from iamsims/fix/websocket-keepalive-logs-3033
fix: prevent WebSocket timeout in container logs after 60s of inactivity
2025-11-30 12:52:48 -06:00
фырат ёздэн
56d4e61c1f fix: removed .ts ext 2025-11-30 21:51:56 +03:00
Mauricio Siu
40949f2a8f Merge pull request #3146 from Dokploy/3013-trim-domain-input-in-server-domain-assignation
feat: enhance domain validation by trimming whitespace from host input
2025-11-30 12:43:31 -06:00
Mauricio Siu
fe7a73baee feat: enhance domain validation by trimming whitespace from host input
- Updated the domain validation schema to ensure the host string does not have leading or trailing spaces.
- Added a refinement to the host field to validate and transform the input accordingly.
- Adjusted the create and update domain functions to trim the host value before saving to the database.
2025-11-30 12:41:42 -06:00
фырат ёздэн
7ce36a50e8 chore: edited dockerSafeExec() description 2025-11-30 21:35:05 +03:00
Mauricio Siu
b1505651c2 Merge pull request #3145 from Dokploy/3054-v0257-and-older-request-tab-only-shows-the-current-hour-and-not-the-day-log-stats
feat: set default date range to last 3 days in ShowRequests component
2025-11-30 12:34:40 -06:00
Mauricio Siu
689c689487 feat: set default date range to last 3 days in ShowRequests component
- Introduced a function to automatically set the date range to the last 3 days upon component initialization.
- Updated the reset button to restore the default date range instead of clearing the dates.
2025-11-30 12:34:12 -06:00
Mauricio Siu
1aac5c1670 Merge pull request #3144 from Dokploy/3141-requests-chart-overflowing
refactor: enhance RequestDistributionChart layout and responsiveness
2025-11-30 12:32:07 -06:00
Mauricio Siu
ea83406f6f refactor: enhance RequestDistributionChart layout and responsiveness
- Updated the chart container to improve layout with a fixed height and overflow handling.
- Adjusted margin settings for better spacing and added support for data overflow in the Y-axis.
- Changed the Area chart type to 'monotone' for smoother transitions in the data representation.
2025-11-30 12:31:27 -06:00
autofix-ci[bot]
4f5b557a60 [autofix.ci] apply automated fixes 2025-11-30 18:30:29 +00:00
фырат ёздэн
d09163a24e chore: using new method dockerSafeExec() 2025-11-30 21:30:08 +03:00
фырат ёздэн
e1d8505757 chore: renamed dockerSafeExec 2025-11-30 21:27:07 +03:00
Mauricio Siu
25aecab062 Merge pull request #3142 from Bima42/fix/notification-returns-raw-json-on-email-test
fix(notification): use form validation logic for testing
2025-11-30 12:25:50 -06:00
autofix-ci[bot]
b6de55c4d9 [autofix.ci] apply automated fixes 2025-11-30 18:24:45 +00:00
фырат ёздэн
e22d503182 feat: waiting for the command to run during build and pull 2025-11-30 21:24:23 +03:00
Mauricio Siu
9e11b802fd Merge pull request #3143 from Dokploy/feat/add-args-to-advanced-command
feat: add support for command arguments in application and database s…
2025-11-30 12:24:19 -06:00
Mauricio Siu
adfe29e10c feat: add args field to application configuration in tests
- Introduced an `args` field in the `baseApp` configuration for both drop and traefik test files to support command arguments in application testing.
- This change aligns with recent updates to application schemas, enhancing the flexibility of command handling in tests.
2025-11-30 12:21:02 -06:00
Mauricio Siu
c1d23b18fb refactor: streamline command and args handling in Redis container configuration
- Updated the Redis container build function to simplify the handling of command and arguments.
- Ensured default command and arguments are set when none are provided, improving robustness.
2025-11-30 12:18:47 -06:00
Mauricio Siu
272a8dbdb2 feat: add support for command arguments in application and database schemas
- Updated the application, mariadb, mongo, mysql, postgres, and redis schemas to include an optional `args` field for command arguments.
- Enhanced the AddCommand and ShowCustomCommand components to handle multiple arguments using a dynamic form.
- Modified the database build functions to incorporate the new `args` parameter when creating containers.
- Added SQL migrations to update the database schema for existing applications and services to accommodate the new `args` field.
2025-11-30 12:13:55 -06:00
фырат ёздэн
32631e957a feat: daily docker cleanup changed default 2025-11-30 21:11:16 +03:00
фырат ёздэн
79d3c1d7f3 fix: if a build or pull operation is running during docker cleaning, pause the cleaning 2025-11-30 21:01:14 +03:00
Bima42
dc4e8ecdc9 fix: use form validation logic for testing notif 2025-11-30 18:08:37 +01:00
Mauricio Siu
559753eae3 Merge pull request #3134 from usings/fix/timebadge-position
fix: stabilize `TimeBadge` position
2025-11-30 02:22:22 -06:00
Mauricio Siu
2d0669e288 fix: correct path for OpenAPI documentation in sync workflow
- Updated the directory structure in the OpenAPI sync workflow to ensure the openapi.json file is copied to the correct path (apps/docs/public) for proper deployment.
2025-11-30 01:13:30 -06:00
Mauricio Siu
3f12f20e4c Merge pull request #3139 from Dokploy/feat/sync-open-api-website-docs
Feat/sync open api website docs
2025-11-30 01:10:55 -06:00
Mauricio Siu
4907a021a4 fix: update OpenAPI sync workflow to copy file to correct path
- Changed the destination path for copying openapi.json to the apps/docs/public directory to ensure proper deployment of the OpenAPI documentation.
2025-11-30 01:09:38 -06:00
Mauricio Siu
817825e8bd chore: update OpenAPI sync workflow triggers and paths
- Modified the workflow to trigger on pushes to the 'canary' and 'main' branches.
- Re-enabled path filters for specific directories related to the OpenAPI documentation.
- Removed commented-out sections for clarity and improved workflow readability.
2025-11-30 01:05:54 -06:00
Mauricio Siu
0f632e3f55 chore: update OpenAPI sync workflow to always commit changes
- Modified the workflow to always commit the OpenAPI specification to the website repository, even if no changes are detected.
- Enhanced the copy command to force overwrite the existing openapi.json file.
- Improved commit message formatting by allowing empty commits to ensure consistency in the sync process.
2025-11-30 00:56:55 -06:00
autofix-ci[bot]
8728d4b600 [autofix.ci] apply automated fixes 2025-11-30 06:54:10 +00:00
Mauricio Siu
88b4374019 chore: update OpenAPI sync workflow to commit changes
- Re-enabled the steps to commit the generated OpenAPI specification to the website repository.
- Improved checks for changes in the OpenAPI spec before committing.
- Enhanced commit message formatting for clarity and added a timestamp to the commit.
2025-11-30 00:53:38 -06:00
Dokploy Bot
b91cb6cb5e chore: update OpenAPI specification [skip ci]
Generated from commit: c8277f6573

Triggered by: push
2025-11-30 06:51:50 +00:00
autofix-ci[bot]
c8277f6573 [autofix.ci] apply automated fixes 2025-11-30 06:51:18 +00:00
Mauricio Siu
24c216e61a chore: comment out OpenAPI spec commit steps in workflow
- Commented out the steps related to committing the OpenAPI specification in the GitHub Actions workflow to prevent automatic commits.
- Adjusted the condition for triggering the website sync based on changes detected in the OpenAPI spec.
2025-11-30 00:50:52 -06:00
Dokploy Bot
5c630e7ad7 chore: update OpenAPI specification [skip ci]
Generated from commit: c0dec0ed20

Triggered by: push
2025-11-30 06:45:19 +00:00
Mauricio Siu
c0dec0ed20 chore: update Node.js and pnpm setup in OpenAPI sync workflow
- Upgraded the pnpm action to version 4 for improved performance.
- Specified Node.js version to 20.16.0 and enabled caching for pnpm to optimize dependency installation.
2025-11-30 00:44:25 -06:00
Mauricio Siu
7d9806a050 chore: improve commit message formatting in OpenAPI sync workflow
- Updated the GitHub Actions workflow to format the commit message for OpenAPI specification updates using multiple `-m` flags for better readability and clarity.
- Added `continue-on-error: true` to the repository dispatch step to ensure the workflow proceeds even if the dispatch fails.
2025-11-30 00:42:23 -06:00
Mauricio Siu
96e7b39e3c chore: trigger OpenAPI sync workflow 2025-11-30 00:38:44 -06:00
Mauricio Siu
ded16f39af chore: remove unnecessary whitespace in OpenAPI documentation sync workflow
- Eliminated an empty line in the GitHub Actions workflow file for syncing OpenAPI documentation to improve readability and maintain consistency.
2025-11-30 00:36:16 -06:00
Mauricio Siu
d8e521e4dc chore: comment out paths in OpenAPI documentation sync workflow
- Commented out the paths section in the GitHub Actions workflow for syncing OpenAPI documentation to allow for more flexible triggering without specific path constraints.
2025-11-30 00:31:50 -06:00
Mauricio Siu
67643fe088 chore: update GitHub Actions workflow branch for OpenAPI documentation sync
- Changed the branch trigger for the OpenAPI documentation sync workflow from 'canary' to 'feat/sync-open-api-website-docs' to align with the new feature branch naming convention.
2025-11-30 00:31:07 -06:00
Mauricio Siu
aab982b431 feat: add OpenAPI generation script and workflow
- Introduced a new script to generate OpenAPI specifications for the Dokploy API.
- Added a GitHub Actions workflow to automate the generation and syncing of OpenAPI documentation upon changes in the API routers.
- Updated package.json files to include new commands for generating OpenAPI specifications.
- Added openapi.json to .gitignore to prevent accidental commits of generated files.
2025-11-30 00:30:40 -06:00
Mauricio Siu
362416afa8 Merge pull request #3138 from Dokploy/711-custom-build-server
711 custom build server
2025-11-29 23:54:16 -06:00
Mauricio Siu
035f8835cf fix: use unified server ID for deployment commands in rebuildApplication
- Updated the rebuildApplication function to utilize a consistent server ID by incorporating buildServerId where available.
- Refactored deployment command execution to ensure compatibility with the new server ID logic, enhancing deployment reliability.
2025-11-29 23:25:01 -06:00
Mauricio Siu
8cff84ef54 feat: add build server and registry configurations to database schema
- Created a new SQL file to define the serverType enum and added buildServerId and buildRegistryId columns to the application and deployment tables.
- Established foreign key constraints for buildServerId and buildRegistryId to ensure referential integrity with the server and registry tables.
- Updated the journal and snapshot files to reflect these schema changes, enhancing the overall build server functionality.
2025-11-29 23:09:54 -06:00
Mauricio Siu
742ca00d3d refactor: remove obsolete SQL files and snapshots related to server and application schema updates
- Deleted SQL files for cold server type, careless Odin application properties, and faulty synchronization constraints.
- Removed corresponding snapshot files to maintain consistency in the database schema versioning.
2025-11-29 23:09:43 -06:00
Mauricio Siu
3481da9b0e feat: add build server properties to application models and enhance server creation
- Introduced new properties (buildServerId, buildRegistryId, buildRegistry) in the ApplicationNested model for better build server configuration.
- Updated the CreateServer component to include a default server type for deployments.
- Improved logging messages for clarity during the image upload process.
2025-11-29 23:05:26 -06:00
Mauricio Siu
15634c9f10 feat: integrate build server functionality and enhance deployment process
- Added support for build server configuration in the application dashboard, including new UI elements and validation.
- Updated database schema to include build server associations and foreign key constraints.
- Enhanced deployment logic to utilize build server IDs, improving deployment flexibility.
- Improved logging and user feedback during the build and deployment processes, including new alerts for image download status.
- Refactored application and deployment services to accommodate build server integration.
2025-11-29 23:04:02 -06:00
autofix-ci[bot]
704582f6de [autofix.ci] apply automated fixes 2025-11-30 05:01:51 +00:00
Mauricio Siu
65d962efc8 feat: enhance server validation and setup for build servers
- Added logic to differentiate between build servers and regular servers in the ValidateServer component.
- Updated the server setup process to conditionally install dependencies based on server type.
- Enhanced the default command generation to include specific commands for build servers.
- Improved UI feedback to reflect the server type in the dashboard.
2025-11-29 21:46:12 -06:00
Mauricio Siu
78d2e13dc8 feat: add build server configuration to application dashboard
- Introduced a new component for configuring build servers in the application dashboard.
- Implemented form validation using Zod and integrated API calls for updating build server settings.
- Enhanced server and application schemas to support build server and registry associations.
- Updated UI to display build server options and provide user feedback on updates.
2025-11-29 21:22:35 -06:00
Mauricio Siu
28f7fb90c0 feat: add informational alert for domain changes in AddDomain component
- Introduced an info alert in the AddDomain component to remind users to redeploy their compose after making changes to domains, enhancing user awareness and experience.
2025-11-29 20:55:04 -06:00
Joie
9213061c26 fix: remove broken Rancher Docker install script URL 2025-11-30 02:05:39 +08:00
Joie
085ef35b46 fix: align DOCKER_VERSION with official installation script 2025-11-29 18:44:11 +08:00
Joie
8647e7a6b7 fix: stabilize TimeBadge position 2025-11-29 18:19:13 +08:00
Mauricio Siu
cc1620b5fa Merge pull request #3133 from Dokploy/feat/add-test-for-deployments
test: add e2e tests for deployments (nixpacks, dockerfile, git)
2025-11-29 01:16:55 -06:00
Mauricio Siu
27b605f961 refactor: update comments and improve clarity in application real tests
- Translated comments from Spanish to English for better accessibility.
- Enhanced comment clarity to improve understanding of test behavior and expectations.
2025-11-29 01:16:14 -06:00
Mauricio Siu
a72281c018 refactor: enhance StopGracePeriod handling in database builders
- Updated the condition for StopGracePeriod in various database builder files to check for null and undefined values, improving code robustness and clarity.
2025-11-29 01:07:22 -06:00
Mauricio Siu
aa750be036 Merge branch 'canary' into feat/add-test-for-deployments 2025-11-29 01:04:58 -06:00
Mauricio Siu
067777f28e feat: initialize Docker Swarm in CI workflow
- Added a step to initialize Docker Swarm and create an overlay network for testing jobs.
- This enhancement improves the CI environment setup for containerized testing.
2025-11-29 00:55:14 -06:00
Mauricio Siu
f77a67ba33 refactor: improve type safety in application command test mock
- Updated the type definition for the createChainableMock function to enhance type safety.
- Ensured that the returning method in the mock returns a properly typed value.
2025-11-29 00:47:31 -06:00
Mauricio Siu
30d2f38259 feat: enhance CI workflow with Nixpacks and Railpack installation
- Added steps to install Nixpacks and Railpack in the CI workflow for testing jobs.
- Updated the PATH to include build tools for better accessibility during the build process.
- Improved Vitest configuration to ensure proper TypeScript path resolution.
2025-11-29 00:44:44 -06:00
Mauricio Siu
b23ba17a41 Merge pull request #3073 from perinm/fix/stop-grace-period-swarm
fix: apply stop grace period within container spec
2025-11-28 10:42:26 -06:00
Mauricio Siu
218c077255 refactor: simplify StopGracePeriod handling in container specifications
- Updated the handling of StopGracePeriod in various database builders to streamline the condition check, improving code readability and maintainability.
2025-11-28 10:41:33 -06:00
ChristoferMendes
c9f356e314 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-11-28 09:41:09 -03:00
Tony
4f691d27b2 feat: persist search query in URL parameters on projects page
Fixes #3101
2025-11-27 15:23:43 +02:00
фырат ёздэн
3c70db9fc8 fix: docker cleanup clears away all unused residue 2025-11-26 22:50:59 +03:00
Mauricio Siu
f94d5b9582 Merge pull request #3118 from Dokploy/3007-gitlab-oauth-error-the-requested-scope-is-invalid-due-to-scope-instead-of-scopes-in-oauth-url-v0256
fix: correct query parameter name in GitLab authorization URL
2025-11-26 12:18:25 -05:00
Mauricio Siu
b9d05b00a9 fix: correct query parameter name in GitLab authorization URL 2025-11-26 11:17:28 -06:00
Mauricio Siu
f61fb3aba0 chore: update dokploy version to v0.25.11 2025-11-26 03:13:26 -05:00
Mauricio Siu
d3b7e68da9 Merge pull request #3032 from fir4tozden/canary
chore: naming of redis and postgres volumes has been made understandable
2025-11-26 03:04:21 -05:00
Mauricio Siu
061ca6c95c Merge pull request #3058 from shiqocred/canary
fix: server time is incorrect
2025-11-26 03:03:30 -05:00
Mauricio Siu
e576c1a63f Merge pull request #2698 from Harikrishnan1367709/Users-are-unable-to-see-requests-#2687
Fix: Allow organization members to access requests functionality (#2687)
2025-11-26 03:01:48 -05:00
Mauricio Siu
5d53cf4090 Merge pull request #3113 from Dokploy/Wrong-Railpack-version-in-the-build-logs-#2535
feat: add Railpack installation command to builder script
2025-11-26 03:00:19 -05:00
Mauricio Siu
ff27f0828b feat: add Railpack installation command to builder script
- Introduced a command to set the RAILPACK_VERSION environment variable and execute the Railpack installation script.
- This enhancement ensures that the correct version of Railpack is used during the build process.
2025-11-26 02:59:32 -05:00
Mauricio Siu
33d4f57611 Merge pull request #3112 from Dokploy/Email-test-notification-always-successful.-#2841
refactor: improve error handling in notification components
2025-11-26 02:40:46 -05:00
Mauricio Siu
bacadccaa9 refactor: improve error handling in notification components
- Enhanced error messages in HandleNotifications and notificationRouter to provide more specific feedback.
- Updated email and Discord notification functions to throw detailed errors on failure.
- Ensured consistent error handling across notification utilities for better debugging.
2025-11-26 02:39:01 -05:00
Mauricio Siu
55748749fd Merge pull request #3110 from Dokploy/3096-show-all-users-not-working-in-dashboardsettingsusers
refactor: remove TableCaption from user display in dashboard settings
2025-11-26 02:25:24 -05:00
Mauricio Siu
45b75fdfde refactor: remove TableCaption from user display in dashboard settings 2025-11-26 02:24:52 -05:00
Mauricio Siu
ff822481c5 Merge pull request #3109 from Dokploy/3097-actions-section-not-working-on-users-page
refactor: enhance user management actions in dashboard
2025-11-26 02:23:35 -05:00
Mauricio Siu
783324628f refactor: enhance user management actions in dashboard
- Updated the user action dropdown to conditionally display options based on user role, ensuring that only non-owner users can access certain actions.
- Improved the delete and unlink user functionalities with better error handling and success notifications.
- Streamlined the code for clarity and maintainability.
2025-11-26 02:23:12 -05:00
Mauricio Siu
e70c476c9f Merge pull request #3108 from Dokploy/2690-when-querying-gitlab-projects-using-the-parameter-ownedtrue-prevents-me-from-finding-more-projects
fix: correct GitLab API URL by removing 'owned' parameter from projec…
2025-11-26 02:09:51 -05:00
Mauricio Siu
891260fe41 fix: correct GitLab API URL by removing 'owned' parameter from project fetch request 2025-11-26 02:09:06 -05:00
Mauricio Siu
062037a9e6 Merge pull request #3107 from Dokploy/2883-s3-destinations-test-connection-fails-to-quote-region
fix: update rclone S3 flags to use quotes for improved parsing
2025-11-26 02:04:38 -05:00
Mauricio Siu
7da1be877b fix: update rclone S3 flags to use quotes for improved parsing
- Added quotes around S3 configuration options in rclone flags to ensure proper handling of special characters and spaces.
- This change enhances the reliability of the S3 integration by preventing potential parsing issues.
2025-11-26 02:04:15 -05:00
Mauricio Siu
60e6285e8e Merge pull request #3106 from Dokploy/2884-expired-authentication-not-displayed-on-the-ui
feat: add additional rclone configuration options for S3 integration
2025-11-26 02:00:48 -05:00
Mauricio Siu
cd8c67bb9b feat: add additional rclone configuration options for S3 integration
- Introduced new rclone flags: --retries, --low-level-retries, --timeout, and --contimeout to enhance S3 operations.
- These options aim to improve reliability and performance during file transfers.
2025-11-26 02:00:14 -05:00
Mauricio Siu
4fb3ad3032 Merge pull request #3048 from Bima42/fix/update-pg-data-path
fix: update pg data path for latest docker version
2025-11-26 01:22:12 -05:00
Mauricio Siu
736a7320d4 refactor: remove unused mount-related logic from postgres router
- Removed the findMountsByApplicationId and updateMount functions from the postgres router as they are no longer needed after the recent refactor of the getMountPath function.
- Cleaned up the code to streamline the update process for PostgreSQL instances.
2025-11-26 01:21:56 -05:00
Mauricio Siu
23b235303c refactor: move getMountPath function to services and update logic
- Moved the getMountPath function from the postgres router to the postgres service for better organization.
- Updated the logic to return the correct mount path based on the PostgreSQL version, ensuring compatibility with versions below 18.
2025-11-26 01:20:40 -05:00
Mauricio Siu
eb8c6e4367 Merge pull request #3105 from Dokploy/Failed-to-schedule-a-backup-for-a-non-existent-(deleted)-database
refactor: improve cleanup operation handling in postgres router
2025-11-26 01:14:08 -05:00
Mauricio Siu
965f05c7c8 refactor: improve cleanup operation handling in postgres router
- Changed cleanup operations to use async functions for better error handling.
- Replaced Promise.allSettled with a for loop to individually await each operation, allowing for more granular error management.
2025-11-26 01:12:39 -05:00
Mauricio Siu
e316beaddb Merge pull request #3104 from Dokploy/fix/error-parsing
feat: enhance error handling in deployment processes
2025-11-26 00:53:07 -05:00
Mauricio Siu
8aff1e7614 refactor: simplify execAsync options type for better clarity
- Updated the options parameter in execAsync to a more streamlined type, focusing on cwd, env, and shell properties for improved clarity and usability.
2025-11-26 00:50:51 -05:00
Mauricio Siu
dbe1733dcb refactor: update execAsync options type for improved flexibility
- Enhanced the options parameter in execAsync to accept ObjectEncodingOptions and ExecOptions, allowing for more versatile command execution configurations.
2025-11-26 00:20:39 -05:00
Mauricio Siu
73d87c06e1 feat: enhance error handling in deployment processes
- Introduced a new ExecError class to standardize error handling during command execution.
- Updated deployApplication and deployCompose functions to log detailed error messages, excluding ExecError instances.
- Improved execAsync and execAsyncRemote functions to throw ExecError with additional context for better debugging.
- Added base64 encoding for error messages to ensure sensitive information is handled appropriately.
2025-11-26 00:11:43 -05:00
Mauricio Siu
e136934cbc Fix newline at end of .env.example file 2025-11-25 12:02:52 -05:00
Mauricio Siu
4840abe3a4 Specify Docker version in installation script 2025-11-22 09:55:35 -05:00
Mauricio Siu
f046ba427a Merge pull request #3083 from Dokploy/2992-inconsistent-date-formats-for-environments
feat: add SQL script to standardize date formats in environment table
2025-11-21 15:02:52 -05:00
Mauricio Siu
b12e84c645 feat: add SQL script to standardize date formats in environment table
- Introduced a new SQL script to update the `createdAt` field in the `environment` table, converting PostgreSQL timestamp formats to ISO 8601 format.
- This change addresses issue #2992, ensuring consistency in date formats across environments.
- Updated journal to include the new migration tag for tracking purposes.
2025-11-21 15:02:17 -05:00
Mauricio Siu
d18fe8390b Merge pull request #3082 from Dokploy/3077-cant-put-space-in-services-name
fix: update input handling in application components
2025-11-21 11:58:59 -05:00
Mauricio Siu
e88a9ce96f fix: update input handling in application components
- Modified the onChange event for input fields in AddApplication, AddCompose, and AddDatabase components to ensure proper trimming of whitespace from the input value before slugification.
2025-11-21 11:58:23 -05:00
Jordan B
5890b321b2 fix: parse CPU value for progress component in monitoring dashboard 2025-11-21 13:38:09 +01:00
Lucas Manchine
1c652477fb fix: apply stop grace period within container spec 2025-11-20 16:15:52 -03:00
Mauricio Siu
a5abd46386 Merge pull request #3071 from Dokploy/fix/adjust-export-envs-stack
fix: improve Docker command execution by including environment variab…
2025-11-20 08:49:17 -06:00
Mauricio Siu
ad0e044740 chore: bump version to v0.25.10 in package.json 2025-11-20 08:48:33 -06:00
Mauricio Siu
7a0ff72f51 fix: improve Docker command execution by including environment variable exports
- Updated the Docker command execution to include environment variable exports directly in the command, enhancing the handling of environment variables during deployment.
- Simplified the export command structure for better readability and efficiency.

Fix https://github.com/Dokploy/dokploy/pull/3066#issuecomment-3558022350
2025-11-20 08:43:24 -06:00
Mauricio Siu
2e702dc41f Merge pull request #2952 from spacewaterbear/add_env_in_notifications
feat: display environnement in notification
2025-11-19 23:30:27 -06:00
Mauricio Siu
766f9244da Merge branch 'canary' into add_env_in_notifications 2025-11-19 23:24:39 -06:00
Mauricio Siu
6413fa54e6 chore: add shell-quote dependency and its type definitions
- Added `shell-quote` to dependencies for improved shell argument handling.
- Included `@types/shell-quote` in devDependencies for TypeScript support.
2025-11-19 22:55:53 -06:00
Mauricio Siu
1c9dcc0c9e Merge pull request #3066 from Dokploy/fix/nixpacks-builder
feat: enhance environment variable handling for shell commands
2025-11-19 21:27:04 -06:00
Mauricio Siu
fee802a57b refactor: remove outdated comment in railpack command builder
- Removed a comment regarding the use of shell-quote for escaping export statements, as the functionality is now handled by the `prepareEnvironmentVariablesForShell` function introduced in a previous commit.
2025-11-19 21:18:13 -06:00
Mauricio Siu
af2b053caa feat: enhance environment variable handling for shell commands
- Added `prepareEnvironmentVariablesForShell` function to properly escape environment variables for shell usage.
- Updated various builders (Docker, Heroku, Nixpacks, Paketo, Railpack) to utilize the new function for improved handling of special characters in environment variables.
- Introduced tests to validate the handling of environment variables with various special characters, ensuring robustness in shell command execution.
- Added `shell-quote` dependency to manage quoting of shell arguments effectively.
2025-11-19 21:17:09 -06:00
Mauricio Siu
42a4cc7fff chore: bump version to v0.25.9 in package.json 2025-11-19 10:14:20 -06:00
Mauricio Siu
2a7807c2b3 Merge pull request #3062 from Dokploy/3061-dokploy-instance-env-variables-override-compose-env
fix: update Docker command execution to use a clean environment
2025-11-19 10:00:58 -06:00
фырат ёздэн
153390ff26 Update settings.ts 2025-11-19 18:59:19 +03:00
Mauricio Siu
425b8ec3c2 fix: update Docker command execution to use a clean environment
- Modified Docker command invocations in compose service functions to use `env -i PATH="$PATH"` for improved environment isolation.
- Ensured consistent handling of Docker commands across `removeCompose`, `startCompose`, and `stopCompose` functions in `compose.ts`.
- Updated command execution in the builders to maintain environment integrity during Docker operations.
2025-11-19 09:58:16 -06:00
фырат ёздэн
e86caccfd5 Merge branch 'Dokploy:canary' into canary 2025-11-19 18:49:38 +03:00
фырат ёздэн
8a93116ce0 fix: update docker cleanup commands to include --all options 2025-11-19 18:48:29 +03:00
autofix-ci[bot]
daff2adb02 [autofix.ci] apply automated fixes 2025-11-19 12:35:39 +00:00
MacBook Pro
052fc5ffe1 fix: server time is incorrect 2025-11-19 19:14:48 +07:00
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
autofix-ci[bot]
a79afe49b4 [autofix.ci] apply automated fixes 2025-11-19 04:46:00 +00:00
Andre Sousa
48503c96c1 fix(bunny.net): Update CDN IP ranges 2025-11-18 22:48:13 +00:00
Bima42
d08fdeb939 fix: only upgrade those that use default pg path 2025-11-18 19:47:29 +01:00
Bima42
8ca8839d7e fix: update mount path on editing pg image 2025-11-18 19:40:00 +01:00
iamsims
8b13919d3b fix: prevent WebSocket timeout in container logs after 60s of inactivity
Fixes #3033

The container logs WebSocket connection was closing after approximately
60 seconds of inactivity with error code 1006 (abnormal closure).
This required users to manually refresh the page to re-establish the
connection, making it difficult to monitor containers that produce logs
infrequently.

Changes:
- Added WebSocket ping mechanism sending ping frames every 45 seconds
- Ensures connection stays alive indefinitely during periods of no log activity
- Properly cleanup ping intervals on connection close (3 locations)
- Prevents memory leaks by clearing intervals on error and close events

The browser automatically responds with pong frames, keeping the
connection alive without requiring any client-side changes.
2025-11-16 18:03:45 -06:00
фырат ёздэн
b2264a9148 chore: naming of postgres volume has been made understandable 2025-11-16 20:34:55 +03:00
фырат ёздэн
f7ddc715c7 chore: naming of redis volume has been made understandable 2025-11-16 20:34:39 +03: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
d02913d69e Merge branch 'canary' into multiple-admins 2025-11-13 22:40:49 -06:00
HarikrishnanD
c459997453 fix(traefik): validate port 8080 before enabling dashboard 2025-11-13 11:52:06 +05:30
ChristoferMendes
1c0673b327 feat: update server package with dbml script runner 2025-11-11 09:16:45 -03:00
ChristoferMendes
334d9c91ef Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-11-11 09:11:38 -03:00
HarikrishnanD
615d89ee0c feat(requests): conditionally render traefik reload warning 2025-11-07 11:40:30 +05:30
spacewaterbear
63568a4887 feat: display environnement in notification 2025-11-03 23:27:18 +01:00
ChristoferMendes
0c24507872 chore: run format-and-lint:fix 2025-11-03 09:44:14 -03:00
ChristoferMendes
d2cd01aff7 chore: run dbml.ts script to update schema.dbml 2025-11-03 09:41:45 -03:00
ChristoferMendes
6349cabf27 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-11-03 09:35:37 -03:00
ChristoferMendes
94536ab05a Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-31 11:48:35 -03:00
autofix-ci[bot]
8e5be8dbcb [autofix.ci] apply automated fixes 2025-10-23 12:00:30 +00:00
HarikrishnanD
046606e496 feat: add volume backup notification support (#2875) 2025-10-23 17:28:24 +05:30
ChristoferMendes
e9cf1f4caa Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-21 14:35:58 -03:00
ChristoferMendes
ee0a299343 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-09 11:08:07 -03:00
NeoIsRecursive
1b77c8029b wip 2025-10-04 10:56:53 +02:00
google-labs-jules[bot]
e4aefe7f9d feat: Add theme-aware top-loading progress bar
This commit introduces a top-loading progress bar that provides visual feedback during page transitions, improving the user's navigation experience.

- **Package Integration:** The `nextjs-toploader` package has been added to provide a lightweight and efficient progress bar solution for Next.js.
- **Theme-Aware Color:** The progress bar's color is dynamically set using the `hsl(var(--sidebar-ring))` CSS variable, ensuring it automatically adapts to the application's current theme (light or dark mode).
- **Implementation:** The `NextTopLoader` component is integrated into the main `_app.tsx` file, making it active across the entire application.
2025-10-01 03:35:29 +00:00
google-labs-jules[bot]
15c81a0982 feat: Add top-loading progress bar
Adds a top-loading progress bar that appears during page transitions to improve user experience by providing visual feedback during navigation.

- Integrated the `nextjs-toploader` package, a lightweight and efficient solution for Next.js applications.
- The progress bar is initialized in the main `_app.tsx` file to ensure it's active across the entire application.
- This feature works seamlessly with the Next.js App Router and does not interfere with server-side rendering (SSR).
2025-10-01 03:13:42 +00:00
ChristoferMendes
48e4fd3ddf Merge branch 'feature/add-custom-webhook-notification-provider' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-09-29 08:54:21 -03:00
ChristoferMendes
276f870e74 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-09-29 08:54:04 -03:00
Omar Elshenhabi
6e86fafa5e fix: improve domain and letsencrypt email validation 2025-09-29 01:15:51 +03:00
autofix-ci[bot]
008788a38a [autofix.ci] apply automated fixes 2025-09-27 09:18:19 +00:00
ChristoferMendes
b4a14e6e76 feat(schema): expand database schema with new enums, tables, and relationships for enhanced application management; Ran dbml.ts script 2025-09-26 17:18:47 -03:00
ChristoferMendes
e64ee98d99 feat(notifications): implement custom notification form fields and connection testing for custom notifications 2025-09-26 17:13:33 -03:00
ChristoferMendes
95d0da25a0 feat(notifications): introduce KeyValueInput component for managing key-value pairs in notifications 2025-09-26 17:13:28 -03:00
ChristoferMendes
0cc8c02359 feat(notifications): add custom notification type icon and improve notification display logic 2025-09-26 17:08:06 -03:00
ChristoferMendes
7f3fe52b53 feat(notifications): add custom notification type and enhance notification schema with custom handling 2025-09-26 17:07:52 -03:00
ChristoferMendes
b5bc384664 feat(notifications): enhance notification router with custom notification handling and additional notification types 2025-09-26 17:07:40 -03:00
ChristoferMendes
39d0b9649f feat(notifications): add support for custom notifications in notification service 2025-09-26 17:06:45 -03:00
ChristoferMendes
1ce880bd6d feat(notifications): integrate custom notification handling in server threshold alerts 2025-09-26 17:06:31 -03:00
ChristoferMendes
8a8ed58fef feat(notifications): add custom notification support for Dokploy server restart 2025-09-26 17:06:27 -03:00
ChristoferMendes
95714c1749 feat(notifications): implement custom notification for Docker cleanup completion 2025-09-26 17:06:11 -03:00
ChristoferMendes
a181b7b8b8 feat(notifications): add custom notification support for database backup status 2025-09-26 17:06:06 -03:00
ChristoferMendes
0e2f1e2832 feat(notifications): enhance build success notifications to include custom notification support 2025-09-26 17:05:59 -03:00
ChristoferMendes
2ec495b2f2 feat(notifications): add support for custom notifications in build error alerts 2025-09-26 17:05:53 -03:00
HarikrishnanD
4b44bc86b4 fix: allow all organization members to access requests functionality - Change requests-related procedures from adminProcedure to protectedProcedure - Fixes issue where members with all permissions couldn't see/activate requests - Affects readStatsLogs, haveActivateRequests, toggleRequests, updateLogCleanup, and getLogCleanupStatus - Resolves #2687 2025-09-26 18:30:38 +05:30
Vlad Vladov
178ccb3f45 feat(ui): Improve UI for admins and owners
- Make 3 dots unclickable if there no available actions for an user.
- Remove "Add permissions" for admins because they have same permissions
as owner
2025-09-03 16:46:55 +03:00
Vlad Vladov
a47a5f3b9e feat(permissions): Forbid admins to delete themselves and add protections to the route 2025-09-03 16:36:22 +03:00
Vlad Vladov
95bf60ac75 fix(template): space for correct checkbox displaying 2025-09-03 02:20:28 +03:00
Vlad Vladov
544408886e feat(permissions): Add multiple admins capability 2025-09-03 02:01:14 +03:00
186 changed files with 98697 additions and 2388 deletions

View File

@@ -20,6 +20,32 @@ jobs:
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.39.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.0
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"
- name: Add build tools to PATH
if: matrix.job == 'test'
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Initialize Docker Swarm
if: matrix.job == 'test'
run: |
docker swarm init
docker network create --driver overlay dokploy-network || true
echo "✅ Docker Swarm initialized"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}

70
.github/workflows/sync-openapi-docs.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Generate and Sync OpenAPI
on:
push:
branches:
- canary
- main
paths:
- 'apps/dokploy/server/api/routers/**'
- 'packages/server/src/services/**'
- 'packages/server/src/db/schema/**'
workflow_dispatch:
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate OpenAPI specification
run: |
pnpm generate:openapi
# Verifica que se generó correctamente
if [ ! -f openapi.json ]; then
echo "❌ openapi.json not found"
exit 1
fi
echo "✅ OpenAPI specification generated successfully"
- name: Sync to website repository
run: |
# Clona el repositorio de website
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
cd website-repo
# Copia el openapi.json al website (sobrescribe)
mkdir -p apps/docs/public
cp -f ../openapi.json apps/docs/public/openapi.json
# Configura git
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
# Agrega y commitea siempre
git add apps/docs/public/openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to website successfully"

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ node_modules
.env.test.local
.env.production.local
openapi.json
# Testing
coverage

View File

@@ -46,7 +46,7 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
# Install docker
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash

View File

@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
# Deploy only the dokploy app
ARG NEXT_PUBLIC_UMAMI_HOST
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
# ARG NEXT_PUBLIC_UMAMI_HOST
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY

View File

@@ -1,3 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
NODE_ENV=development

View File

@@ -0,0 +1,215 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { parse, stringify } from "yaml";
import { describe, expect, it } from "vitest";
/**
* Regression tests for Traefik Host rule label format.
*
* These tests verify that the Host rule is generated with the correct format:
* - Host(`domain.com`) - with opening and closing parentheses
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
*
* Issue: https://github.com/Dokploy/dokploy/issues/3161
* The bug caused Host rules to be malformed as Host`domain.com`)
* (missing opening parenthesis) which broke all domain routing.
*/
describe("Host rule format regression tests", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",
domainType: "compose",
serviceName: "test-app",
domainId: "",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
};
describe("Host rule format validation", () => {
it("should generate Host rule with correct parentheses format", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify exact format: Host(`domain`)
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
// Ensure opening parenthesis is present after Host
expect(ruleLabel).toContain("Host(`example.com`)");
// Ensure it does NOT have the malformed format
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should generate PathPrefix with correct parentheses format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify PathPrefix format
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
expect(ruleLabel).toContain("PathPrefix(`/api`)");
// Ensure opening parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
it("should generate combined Host and PathPrefix with correct format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api/v1" },
"websecure",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toBe(
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
);
});
});
describe("YAML serialization preserves Host rule format", () => {
it("should preserve Host rule format through YAML stringify/parse", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
// Simulate compose file structure
const composeSpec = {
services: {
myapp: {
image: "nginx",
labels: labels,
},
},
};
// Stringify to YAML
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
// Parse back
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
// Verify format is preserved
expect(parsedRuleLabel).toBe(ruleLabel);
expect(parsedRuleLabel).toContain("Host(`example.com`)");
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should preserve complex rule format through YAML serialization", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api", https: true },
"websecure",
);
const composeSpec = {
services: {
myapp: {
labels: labels,
},
},
};
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
expect(parsedRuleLabel).toContain(
"Host(`example.com`) && PathPrefix(`/api`)",
);
});
});
describe("Edge cases for domain names", () => {
const domainCases = [
{ name: "simple domain", host: "example.com" },
{ name: "subdomain", host: "app.example.com" },
{ name: "deep subdomain", host: "api.v1.app.example.com" },
{ name: "numeric domain", host: "123.example.com" },
{ name: "hyphenated domain", host: "my-app.example-host.com" },
{ name: "localhost", host: "localhost" },
{ name: "IP address style", host: "192.168.1.100" },
];
for (const { name, host } of domainCases) {
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
// Verify parenthesis is present
expect(ruleLabel).toMatch(
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
);
});
}
});
describe("Multiple domains scenario", () => {
it("should generate correct format for both web and websecure entrypoints", async () => {
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
const websecureLabels = await createDomainLabels(
"test-app",
baseDomain,
"websecure",
);
const webRule = webLabels.find((l) => l.includes(".rule="));
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
// Both should have correct format
expect(webRule).toContain("Host(`example.com`)");
expect(websecureRule).toContain("Host(`example.com`)");
// Neither should have malformed format
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
});
});
describe("Special characters in paths", () => {
const pathCases = [
{ name: "simple path", path: "/api" },
{ name: "nested path", path: "/api/v1/users" },
{ name: "path with hyphen", path: "/api-v1" },
{ name: "path with underscore", path: "/api_v1" },
];
for (const { name, path } of pathCases) {
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
// Verify parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
}
});
});

View File

@@ -0,0 +1,276 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import * as gitProvider from "@dokploy/server/utils/providers/git";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
} as any;
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/providers/git", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/providers/git")
>("@dokploy/server/utils/providers/git");
return {
...actual,
getGitCommitInfo: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
buildType: "nixpacks" as const,
buildPath: "/astro",
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-deployment.log",
});
describe("deployApplication - Command Generation Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
message: "test commit",
hash: "abc123",
});
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
});
it("should generate correct git clone command for astro example", async () => {
const app = createMockApplication();
const command = await cloneGitRepository(app);
console.log(command);
expect(command).toContain("https://github.com/Dokploy/examples.git");
expect(command).not.toContain("--recurse-submodules");
expect(command).toContain("--branch main");
expect(command).toContain("--depth 1");
expect(command).toContain("git clone");
});
it("should generate git clone with submodules when enabled", async () => {
const app = createMockApplication({ enableSubmodules: true });
const command = await cloneGitRepository(app);
expect(command).toContain("--recurse-submodules");
expect(command).toContain("https://github.com/Dokploy/examples.git");
});
it("should verify nixpacks command is called with correct app", async () => {
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test deployment",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "nixpacks",
customGitUrl: "https://github.com/Dokploy/examples.git",
buildPath: "/astro",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("nixpacks build"),
);
});
it("should verify railpack command includes correct parameters", async () => {
const mockApp = createMockApplication({ buildType: "railpack" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Railpack test",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "railpack",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("railpack prepare"),
);
});
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
expect(execCalls.length).toBeGreaterThan(0);
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("git clone");
expect(fullCommand).toContain("nixpacks build");
});
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
});
});

View File

@@ -0,0 +1,479 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { format } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain: any = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
};
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
// - getBuildCommand
// - mechanizeDockerContainer (requires Docker Swarm)
import { db } from "@dokploy/server/db";
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
applicationId: "test-app-id",
name: "Real Test App",
appName: `real-test-${Date.now()}`,
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
customGitBuildPath: "/astro",
buildType: "nixpacks" as const,
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
mounts: [],
security: [],
redirects: [],
ports: [],
registry: null,
...overrides,
}) as ApplicationNested;
const createMockDeployment = async (appName: string) => {
const { LOGS_PATH } = paths(false); // false = local, no remote server
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
// Actually create the log directory
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
return {
deploymentId: "deployment-id",
logPath: logFilePath,
};
};
async function cleanupDocker(appName: string) {
try {
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
} catch (error) {
console.log("Docker cleanup completed");
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
// Clean cloned code directories
const appPath = path.join(APPLICATIONS_PATH, appName);
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
// Clean logs for appName - removes entire folder
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
console.log(`✅ Cleaned up files and logs for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"deployApplication - REAL Execution Tests",
() => {
let currentAppName: string;
let currentDeployment: any;
const allTestAppNames: string[] = [];
beforeEach(async () => {
vi.clearAllMocks();
currentAppName = `real-test-${Date.now()}`;
currentDeployment = await createMockDeployment(currentAppName);
allTestAppNames.push(currentAppName);
const mockApp = createMockApplication({ appName: currentAppName });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
currentDeployment as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
{} as any,
);
});
afterEach(async () => {
// ALWAYS cleanup, even if test failed or passed
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
// Clean current appName
try {
await cleanupDocker(currentAppName);
await cleanupFiles(currentAppName);
} catch (error) {
console.error("⚠️ Error cleaning current app:", error);
}
// Clean ALL test folders just in case
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
await execAsync(
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
);
console.log("✅ Cleaned up all test artifacts");
} catch (error) {
console.error("⚠️ Error cleaning all artifacts:", error);
}
console.log("✅ Cleanup completed\n");
});
it(
"should REALLY clone git repo and build with nixpacks",
async () => {
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Nixpacks Test",
descriptionLog: "Testing real execution",
});
expect(result).toBe(true);
// Verify that Docker image was actually created
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
// Verify log exists and has content
expect(existsSync(currentDeployment.logPath)).toBe(true);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent).toContain("nixpacks");
console.log(`✅ Build log created with ${logContent.length} chars`);
// Verify update functions were called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"done",
);
},
REAL_TEST_TIMEOUT,
);
it.skip(
"should REALLY build with railpack (SKIPPED: requires special permissions)",
async () => {
const railpackAppName = `real-railpack-${Date.now()}`;
const railpackApp = createMockApplication({
appName: railpackAppName,
buildType: "railpack",
railpackVersion: "3",
});
currentAppName = railpackAppName;
allTestAppNames.push(railpackAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
railpackApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
railpackApp as any,
);
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Railpack Test",
descriptionLog: "",
});
expect(result).toBe(true);
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Railpack image created: ${currentAppName}`);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("railpack");
console.log("✅ Railpack build completed");
},
REAL_TEST_TIMEOUT,
);
it(
"should handle REAL git clone errors",
async () => {
const errorAppName = `real-error-${Date.now()}`;
const errorApp = createMockApplication({
appName: errorAppName,
customGitUrl:
"https://github.com/invalid/nonexistent-repo-123456.git",
});
currentAppName = errorAppName;
allTestAppNames.push(errorAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
errorApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
errorApp as any,
);
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
await expect(
deployApplication({
applicationId: "test-app-id",
titleLog: "Real Error Test",
descriptionLog: "",
}),
).rejects.toThrow();
// Verify error status was called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"error",
);
// Verify log contains error
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent.toLowerCase()).toContain("error");
console.log("✅ Error handling verified");
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY clone with submodules when enabled",
async () => {
const submodulesAppName = `real-submodules-${Date.now()}`;
const submodulesApp = createMockApplication({
appName: submodulesAppName,
enableSubmodules: true,
});
currentAppName = submodulesAppName;
allTestAppNames.push(submodulesAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
submodulesApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
submodulesApp as any,
);
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Submodules Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify deployment completed successfully
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent.length).toBeGreaterThan(100);
console.log("✅ Submodules deployment completed");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
},
REAL_TEST_TIMEOUT,
);
it(
"should verify REAL commit info extraction",
async () => {
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Commit Test",
descriptionLog: "",
});
// Verify updateDeployment was called with commit info
expect(deploymentService.updateDeployment).toHaveBeenCalled();
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
.calls[0];
// Real commit info should have title and hash
expect(updateCall?.[1]).toHaveProperty("title");
expect(updateCall?.[1]).toHaveProperty("description");
expect(updateCall?.[1]?.description).toContain("Commit:");
console.log(
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
);
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY build with Dockerfile",
async () => {
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
const dockerfileApp = createMockApplication({
appName: dockerfileAppName,
buildType: "dockerfile",
customGitBuildPath: "/deno",
dockerfile: "Dockerfile",
});
currentAppName = dockerfileAppName;
allTestAppNames.push(dockerfileAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
dockerfileApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
dockerfileApp as any,
);
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Dockerfile Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify log
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Building");
expect(logContent).toContain(dockerfileAppName);
console.log("✅ Dockerfile build log verified");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
@@ -37,6 +41,9 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",

View File

@@ -1,4 +1,7 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});
it("handles environment variables with single quotes in values", () => {
const envWithSingleQuotes = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
SIMPLE_VAR=no-quotes
`;
const serviceWithSingleQuotes = `
TEST_VAR=\${{environment.ENV_VARIABLE}}
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
SIMPLE=\${{environment.SIMPLE_VAR}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithSingleQuotes,
"",
envWithSingleQuotes,
);
expect(resolved).toEqual([
"TEST_VAR=ENVITONME'NT",
"ANOTHER_TEST=value with 'quotes' inside",
"SIMPLE=no-quotes",
]);
});
});
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
it("escapes single quotes in environment variable values", () => {
const serviceEnv = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote should wrap these in double quotes
expect(resolved).toEqual([
`"ENV_VARIABLE=ENVITONME'NT"`,
`"ANOTHER_VAR=value with 'quotes' inside"`,
]);
});
it("escapes double quotes in environment variable values", () => {
const serviceEnv = `
MESSAGE="Hello "World""
QUOTED_PATH="/path/to/"file""
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote wraps in single quotes when there are double quotes inside
expect(resolved).toEqual([
`'MESSAGE=Hello "World"'`,
`'QUOTED_PATH=/path/to/"file"'`,
]);
});
it("escapes dollar signs in environment variable values", () => {
const serviceEnv = `
PRICE=$100
VARIABLE=$HOME/path
TEMPLATE=Hello $USER
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Dollar signs should be escaped to prevent variable expansion
for (const env of resolved) {
expect(env).toContain("$");
}
});
it("escapes backticks in environment variable values", () => {
const serviceEnv = `
COMMAND=\`echo "test"\`
NESTED=value with \`backticks\` inside
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("COMMAND");
expect(resolved[1]).toContain("NESTED");
});
it("handles environment variables with spaces", () => {
const serviceEnv = `
FULL_NAME="John Doe"
MESSAGE='Hello World'
SENTENCE=This is a test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote uses single quotes for strings with spaces
expect(resolved).toEqual([
`'FULL_NAME=John Doe'`,
`'MESSAGE=Hello World'`,
`'SENTENCE=This is a test'`,
]);
});
it("handles environment variables with backslashes", () => {
const serviceEnv = `
WINDOWS_PATH=C:\\Users\\Documents
ESCAPED=value\\with\\backslashes
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backslashes should be properly escaped
expect(resolved.length).toBe(2);
for (const env of resolved) {
expect(env).toContain("\\");
}
});
it("handles simple environment variables without special characters", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
DEBUG=true
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign in some cases
expect(resolved).toEqual([
"NODE_ENV\\=production",
"PORT\\=3000",
"DEBUG\\=true",
]);
});
it("handles environment variables with mixed special characters", () => {
const serviceEnv = `
COMPLEX='value with "double" and 'single' quotes'
BASH_COMMAND=echo "$HOME" && echo 'test'
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// All should be escaped, none should throw errors
expect(resolved.length).toBe(3);
// Verify each can be safely used in shell
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with newlines", () => {
const serviceEnv = `
MULTILINE="line1
line2
line3"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("MULTILINE");
});
it("handles empty environment variable values", () => {
const serviceEnv = `
EMPTY=
EMPTY_QUOTED=""
EMPTY_SINGLE=''
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign for empty values
expect(resolved).toEqual([
"EMPTY\\=",
"EMPTY_QUOTED\\=",
"EMPTY_SINGLE\\=",
]);
});
it("handles environment variables with equals signs in values", () => {
const serviceEnv = `
EQUATION=a=b+c
CONNECTION_STRING=user=admin;password=test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("EQUATION");
expect(resolved[1]).toContain("CONNECTION_STRING");
});
it("resolves and escapes environment variables together", () => {
const projectEnv = `
BASE_URL=https://example.com
API_KEY='secret-key-with-quotes'
`;
const environmentEnv = `
ENV_NAME=production
DB_PASS='pa$$word'
`;
const serviceEnv = `
FULL_URL=\${{project.BASE_URL}}/api
AUTH_KEY=\${{project.API_KEY}}
ENVIRONMENT=\${{environment.ENV_NAME}}
DB_PASSWORD=\${{environment.DB_PASS}}
CUSTOM='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(resolved.length).toBe(5);
// All resolved values should be properly escaped
for (const env of resolved) {
expect(typeof env).toBe("string");
}
});
it("handles environment variables with semicolons and ampersands", () => {
const serviceEnv = `
COMMAND=echo "test" && echo "test2"
MULTIPLE=cmd1; cmd2; cmd3
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// These should be safely escaped to prevent command injection
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with pipes and redirects", () => {
const serviceEnv = `
PIPE_COMMAND=cat file | grep test
REDIRECT=echo "test" > output.txt
BOTH=cat input.txt | grep pattern > output.txt
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// Pipes and redirects should be safely quoted
expect(resolved[0]).toContain("PIPE_COMMAND");
expect(resolved[1]).toContain("REDIRECT");
expect(resolved[2]).toContain("BOTH");
// At least one should contain a pipe
const hasPipe = resolved.some((env) => env.includes("|"));
expect(hasPipe).toBe(true);
});
it("handles environment variables with parentheses and brackets", () => {
const serviceEnv = `
MATH=(a+b)*c
ARRAY=[1,2,3]
JSON={"key":"value"}
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("(");
expect(resolved[1]).toContain("[");
expect(resolved[2]).toContain("{");
});
it("handles very long environment variable values", () => {
const longValue = "a".repeat(10000);
const serviceEnv = `LONG_VAR=${longValue}`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("LONG_VAR");
expect(resolved[0]?.length).toBeGreaterThan(10000);
});
it("handles special unicode characters in environment variables", () => {
const serviceEnv = `
EMOJI=Hello 🌍 World 🚀
CHINESE=你好世界
SPECIAL=café résumé naïve
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("🌍");
expect(resolved[1]).toContain("你好");
expect(resolved[2]).toContain("café");
});
});

View File

@@ -1,10 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockCreateServiceOptions = {
StopGracePeriod?: number;
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
};
};
[key: string]: unknown;
};
@@ -82,8 +85,10 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.StopGracePeriod).toBe(0);
expect(typeof settings.StopGracePeriod).toBe("number");
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
"number",
);
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
@@ -97,6 +102,8 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings).not.toHaveProperty("StopGracePeriod");
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
"StopGracePeriod",
);
});
});

View File

@@ -18,6 +18,8 @@ const baseAdmin: User = {
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
firstName: "",
lastName: "",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -61,7 +63,6 @@ const baseAdmin: User = {
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",

View File

@@ -11,8 +11,15 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,

View File

@@ -13,7 +13,11 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, "../tsconfig.json")],
}),
],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,13 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [data, form]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0 ? "-c" : "echo Hello World"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">

View File

@@ -0,0 +1,248 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
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,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
applicationId: string;
}
const schema = z.object({
buildServerId: z.string().min(1, "Build server is required"),
buildRegistryId: z.string().min(1, "Build registry is required"),
});
type Schema = z.infer<typeof schema>;
export const ShowBuildServer = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data) {
form.reset({
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
applicationId,
buildServerId:
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId,
buildRegistryId:
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId,
})
.then(async () => {
toast.success("Build Server Settings Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating build server settings");
});
};
return (
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row items-center gap-2">
<Server className="size-6 text-muted-foreground" />
<div>
<CardTitle className="text-xl">Build Server</CardTitle>
<CardDescription>
Configure a dedicated server for building your application.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Build servers offload the build process from your deployment servers.
Select a build server and registry to use for building your
application.
</AlertBlock>
<AlertBlock type="info">
📊 <strong>Important:</strong> Once the build finishes, you'll need to
wait a few seconds for the deployment server to download the image.
These download logs will <strong>NOT</strong> appear in the build
deployment logs. Check the <strong>Logs</strong> tab to see when the
container starts running.
</AlertBlock>
{!registries || registries.length === 0 ? (
<AlertBlock type="warning">
You need to add at least one registry to use build servers. Please
go to{" "}
<Link
href="/dashboard/settings/registry"
className="text-primary underline"
>
Settings
</Link>{" "}
to add a registry.
</AlertBlock>
) : null}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="buildServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Server</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a build server" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{buildServers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Build Servers ({buildServers?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a build server to handle the build process for this
application.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildRegistryId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Registry</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectLabel>
Registries ({registries?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a registry to store the built images from the build
server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

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

@@ -25,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";
@@ -142,7 +143,10 @@ export const ShowDeployments = ({
See the last 10 deployments for this {type}
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
@@ -369,7 +373,19 @@ export const ShowDeployments = ({
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
description={
<div className="flex flex-col gap-3">
<p>
Are you sure you want to rollback to this
deployment?
</p>
<AlertBlock type="info" className="text-sm">
Please wait a few seconds while the image is
pulled from the registry. Your application
should be running shortly.
</AlertBlock>
</div>
}
type="default"
onClick={async () => {
await rollback({
@@ -403,7 +419,7 @@ export const ShowDeployments = ({
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={activeLog?.buildServerId || serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form"

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,13 +21,37 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const formSchema = z.object({
rollbackActive: z.boolean(),
});
const formSchema = z
.object({
rollbackActive: z.boolean(),
rollbackRegistryId: z.string().optional(),
})
.superRefine((values, ctx) => {
if (
values.rollbackActive &&
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["rollbackRegistryId"],
message: "Registry is required when rollbacks are enabled",
});
}
});
type FormValues = z.infer<typeof formSchema>;
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const { data: registries } = api.registry.all.useQuery();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
rollbackActive: application?.rollbackActive ?? false,
rollbackRegistryId: application?.rollbackRegistryId || "",
},
});
useEffect(() => {
if (application) {
form.reset({
rollbackActive: application.rollbackActive ?? false,
rollbackRegistryId: application.rollbackRegistryId || "",
});
}
}, [application, form]);
const onSubmit = async (data: FormValues) => {
await updateApplication({
applicationId,
rollbackActive: data.rollbackActive,
rollbackRegistryId:
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
? null
: data.rollbackRegistryId,
})
.then(() => {
toast.success("Rollback settings updated");
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
)}
/>
{form.watch("rollbackActive") && (
<FormField
control={form.control}
name="rollbackRegistryId"
render={({ field }) => (
<FormItem>
<FormLabel>Rollback Registry</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectLabel>
Registries ({registries?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
{!registries || registries.length === 0 ? (
<FormDescription className="text-amber-600 dark:text-amber-500">
No registries available. Please{" "}
<Link
href="/dashboard/settings/registry"
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
>
configure a registry
</Link>{" "}
first to enable rollbacks.
</FormDescription>
) : (
<FormDescription>
Select a registry where rollback images will be stored.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
)}
<Button type="submit" className="w-full" isLoading={isLoading}>
Save Settings
</Button>

View File

@@ -60,6 +60,30 @@ export const commonCronExpressions = [
{ label: "Custom", value: "custom" },
];
export const commonTimezones = [
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
{
label: "America/Mexico_City (Central Mexico)",
value: "America/Mexico_City",
},
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
{
label: "Australia/Sydney (Australian Eastern Time)",
value: "Australia/Sydney",
},
];
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
@@ -75,6 +99,7 @@ const formSchema = z
"dokploy-server",
]),
script: z.string(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
@@ -213,6 +238,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
timezone: undefined,
},
});
@@ -251,6 +277,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
timezone: schedule.timezone || undefined,
});
}
}, [form, schedule, scheduleId]);
@@ -464,6 +491,54 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
formControl={form.control}
/>
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Timezone
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Select a timezone for the schedule. If not
specified, UTC will be used.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="UTC (default)" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonTimezones.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Optional: Choose a timezone for the schedule execution time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>

View File

@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
// message: "The server is running on port 8080" }
const logRegex =
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
return logString
.split("\n")
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
const match = line.match(logRegex);
if (!match) return null;
const [, , timestamp, message] = match;
const { timestamp, message } = match.groups ?? {};
if (!message?.trim()) return null;

View File

@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
setOpen(false);
toast.success("Successfully impersonating user", {
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
});
window.location.reload();
} catch (error) {
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate flex flex-col items-start">
<span className="text-sm font-medium">
{selectedUser.name || ""}
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
""}
</span>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
<UserIcon className="h-4 w-4 flex-shrink-0" />
<span className="flex flex-col items-start">
<span className="text-sm font-medium">
{user.name || ""}
{`${user.name} ${user.lastName}`.trim() ||
""}
</span>
<span className="text-xs text-muted-foreground">
{user.email} {user.role}
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
alt={
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
""
}
/>
<AvatarFallback>
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => {
Impersonating
</Badge>
<span className="font-medium">
{data?.user?.name || ""}
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
""}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">

View File

@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
const defaultData = {
cpu: {
value: 0,
value: "0%",
time: "",
},
memory: {
@@ -46,7 +46,7 @@ interface Props {
}
export interface DockerStats {
cpu: {
value: number;
value: string;
time: string;
};
memory: {
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
</span>
<Progress value={currentData.cpu.value} className="w-[100%]" />
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
10,
)}
className="w-[100%]"
/>
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
</div>
</CardContent>

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
interface Props {
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
defaultValues: {
dockerImage: "",
command: "",
args: [],
},
resolver: zodResolver(addDockerImage),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data) {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
args: data.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [data, form, form.reset]);
}, [data, form]);
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Custom Command Updated");
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0
? "-c"
: "redis-server --port 6379"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<div className="flex w-full justify-end">
<Button isLoading={form.formState.isSubmitting} type="submit">
Save

View File

@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}

View File

@@ -161,8 +161,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}

View File

@@ -395,8 +395,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
placeholder="Name"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}

View File

@@ -1,15 +1,8 @@
import type { findEnvironmentsByProjectId } from "@dokploy/server";
import {
ChevronDownIcon,
PencilIcon,
PlusIcon,
Terminal,
TrashIcon,
} from "lucide-react";
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -246,20 +239,6 @@ export const AdvancedEnvironmentSelector = ({
)}
</div>
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
{/* <EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
}}
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables> */}
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button

View File

@@ -10,6 +10,7 @@ import {
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
@@ -54,16 +55,23 @@ import {
} from "@/components/ui/select";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
import { useDebounce } from "@/utils/hooks/use-debounce";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const router = useRouter();
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();
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState(
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
);
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("projectsSort") || "createdAt-desc";
@@ -75,14 +83,41 @@ export const ShowProjects = () => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (urlQuery !== searchQuery) {
setSearchQuery(urlQuery);
}
}, [router.isReady, router.query.q]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (debouncedSearchQuery === urlQuery) return;
const newQuery = { ...router.query };
if (debouncedSearchQuery) {
newQuery.q = debouncedSearchQuery;
} else {
delete newQuery.q;
}
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true,
});
}, [debouncedSearchQuery]);
const filteredProjects = useMemo(() => {
if (!data) return [];
// First filter by search query
const filtered = data.filter(
(project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
project.name
.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()) ||
project.description
?.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()),
);
// Then sort the filtered results
@@ -130,7 +165,7 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, searchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy]);
return (
<>
@@ -138,7 +173,7 @@ export const ShowProjects = () => {
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-5 right-5">
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}

View File

@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ResponsiveContainer width="100%" height={200}>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="natural"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};

View File

@@ -51,13 +51,38 @@ export const ShowRequests = () => {
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
// Set default date range to last 3 days
const getDefaultDateRange = () => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 3);
return { from, to };
};
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
}>(getDefaultDateRange());
// Check if logs exist to determine if traefik has been reloaded
// Only fetch when active to minimize network calls
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
{
page: {
pageIndex: 0,
pageSize: 1,
},
},
{
enabled: !!isActive,
refetchInterval: 5000, // Check every 5 seconds when active
},
);
// Determine if warning should be shown
// Show warning only if active but no logs exist yet
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
useEffect(() => {
if (logCleanupStatus) {
@@ -79,16 +104,18 @@ export const ShowRequests = () => {
See all the incoming requests that pass trough Traefik
</CardDescription>
<AlertBlock type="warning">
When you activate, you need to reload traefik to apply the
changes, you can reload traefik in{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
Settings
</Link>
</AlertBlock>
{shouldShowWarning && (
<AlertBlock type="warning">
When you activate, you need to reload traefik to apply the
changes, you can reload traefik in{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
Settings
</Link>
</AlertBlock>
)}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex w-full gap-4 justify-end items-center">
@@ -169,17 +196,13 @@ export const ShowRequests = () => {
{isActive ? (
<>
<div className="flex justify-end mb-4 gap-2">
{(dateRange.from || dateRange.to) && (
<Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Button
variant="outline"
onClick={() => setDateRange(getDefaultDateRange())}
className="px-3"
>
Reset to Last 3 Days
</Button>
<Popover>
<PopoverTrigger asChild>
<Button

View File

@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
return authUrl;
};

View File

@@ -1,12 +1,19 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -26,13 +33,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const Schema = z.object({
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
const utils = api.useUtils();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const { data, refetch } = api.ai.one.useQuery(
{
aiId: aiId || "",
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
});
useEffect(() => {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
if (data) {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}
setModelSearch("");
setModelPopoverOpen(false);
}, [aiId, form, data]);
const apiUrl = form.watch("apiUrl");
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
},
);
useEffect(() => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) {
form.setValue("model", "");
}
}, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setModelSearch("");
setModelPopoverOpen(false);
}
}}
>
<DialogTrigger className="" asChild>
{aiId ? (
<Button
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.openai.com/v1" {...field} />
<Input
placeholder="https://api.openai.com/v1"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API URL
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API Key
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Select an AI model to use</FormDescription>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}

View File

@@ -1,5 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
Mail,
PenBoxIcon,
PlusIcon,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -44,6 +50,7 @@ const notificationBaseSchema = z.object({
appDeploy: z.boolean().default(false),
appBuildError: z.boolean().default(false),
databaseBackup: z.boolean().default(false),
volumeBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false),
serverThreshold: z.boolean().default(false),
@@ -103,10 +110,25 @@ export const notificationSchema = z.discriminatedUnion("type", [
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
accessToken: z.string().min(1, { message: "Access Token is required" }),
accessToken: z.string().optional(),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("custom"),
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional()
.default([]),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
@@ -144,6 +166,10 @@ export const notificationsMap = {
icon: <NtfyIcon />,
label: "ntfy",
},
custom: {
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
label: "Custom",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -179,6 +205,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
const customMutation = notificationId
? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -217,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: "toAddresses" as never,
});
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: "headers" as never,
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
append("");
@@ -231,6 +273,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl,
channel: notification.slack?.channel || "",
@@ -244,6 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
botToken: notification.telegram?.botToken,
messageThreadId: notification.telegram?.messageThreadId || "",
chatId: notification.telegram?.chatId,
@@ -258,6 +302,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
@@ -271,6 +316,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
smtpServer: notification.email?.smtpServer,
smtpPort: notification.email?.smtpPort,
@@ -288,6 +334,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
@@ -302,8 +349,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
accessToken: notification.ntfy?.accessToken || "",
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
@@ -323,6 +371,26 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
endpoint: notification.custom?.endpoint || "",
headers: notification.custom?.headers
? Object.entries(notification.custom.headers).map(
([key, value]) => ({
key,
value,
}),
)
: [],
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
} else {
form.reset();
@@ -337,6 +405,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
custom: customMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -345,6 +414,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy,
dokployRestart,
databaseBackup,
volumeBackup,
dockerCleanup,
serverThreshold,
} = data;
@@ -355,6 +425,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel,
name: data.name,
@@ -369,6 +440,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
botToken: data.botToken,
messageThreadId: data.messageThreadId || "",
chatId: data.chatId,
@@ -384,6 +456,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
decoration: data.decoration,
name: data.name,
@@ -398,6 +471,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
username: data.username,
@@ -416,6 +490,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
@@ -431,8 +506,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
accessToken: data.accessToken || "",
topic: data.topic,
priority: data.priority,
name: data.name,
@@ -453,6 +529,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
promise = customMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
endpoint: data.endpoint,
headers: headersRecord,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
customId: notification?.customId || "",
});
}
if (promise) {
@@ -1001,8 +1103,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Optional. Leave blank for public topics.
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -1039,7 +1145,92 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "custom" && (
<div className="space-y-4">
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://api.example.com/webhook"
{...field}
/>
</FormControl>
<FormDescription>
The URL where POST requests will be sent with
notification data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<div>
<FormLabel>Headers</FormLabel>
<FormDescription>
Optional. Custom headers for your POST request (e.g.,
Authorization, Content-Type).
</FormDescription>
</div>
<div className="space-y-2">
{headerFields.map((field, index) => (
<div
key={field.id}
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
>
<FormField
control={form.control}
name={`headers.${index}.key` as never}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Key" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`headers.${index}.value` as never}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input placeholder="Value" {...field} />
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon className="h-4 w-4 mr-2" />
Add header
</Button>
</div>
</div>
)}
{type === "lark" && (
<>
<FormField
@@ -1130,6 +1321,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)}
/>
<FormField
control={form.control}
name="volumeBackup"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Volume Backup</FormLabel>
<FormDescription>
Trigger the action when a volume backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerCleanup"
@@ -1211,58 +1423,82 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark
isLoadingLark ||
isLoadingCustom
}
variant="secondary"
type="button"
onClick={async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
if (type === "slack") {
if (data.type === "slack") {
await testSlackConnection({
webhookUrl: form.getValues("webhookUrl"),
channel: form.getValues("channel"),
webhookUrl: data.webhookUrl,
channel: data.channel,
});
} else if (type === "telegram") {
} else if (data.type === "telegram") {
await testTelegramConnection({
botToken: form.getValues("botToken"),
chatId: form.getValues("chatId"),
messageThreadId: form.getValues("messageThreadId") || "",
botToken: data.botToken,
chatId: data.chatId,
messageThreadId: data.messageThreadId || "",
});
} else if (type === "discord") {
} else if (data.type === "discord") {
await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
webhookUrl: data.webhookUrl,
decoration: data.decoration,
});
} else if (type === "email") {
} else if (data.type === "email") {
await testEmailConnection({
smtpServer: form.getValues("smtpServer"),
smtpPort: form.getValues("smtpPort"),
username: form.getValues("username"),
password: form.getValues("password"),
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
username: data.username,
password: data.password,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (type === "gotify") {
} else if (data.type === "gotify") {
await testGotifyConnection({
serverUrl: form.getValues("serverUrl"),
appToken: form.getValues("appToken"),
priority: form.getValues("priority"),
decoration: form.getValues("decoration"),
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
decoration: data.decoration,
});
} else if (type === "ntfy") {
} else if (data.type === "ntfy") {
await testNtfyConnection({
serverUrl: form.getValues("serverUrl"),
topic: form.getValues("topic"),
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
serverUrl: data.serverUrl,
topic: data.topic,
accessToken: data.accessToken || "",
priority: data.priority,
});
} else if (type === "lark") {
} else if (data.type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
await testCustomConnection({
endpoint: data.endpoint,
headers: headersRecord,
});
}
toast.success("Connection Success");
} catch {
toast.error("Error testing the provider");
} catch (error) {
toast.error(
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
>

View File

@@ -1,4 +1,4 @@
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
<NtfyIcon className="size-6" />
</div>
)}
{notification.notificationType === "custom" && (
<div className="flex items-center justify-center rounded-lg ">
<PenBoxIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "lark" && (
<div className="flex items-center justify-center rounded-lg">
<LarkIcon className="size-7 text-muted-foreground" />

View File

@@ -41,6 +41,7 @@ const profileSchema = z.object({
currentPassword: z.string().nullable(),
image: z.string().optional(),
name: z.string().optional(),
lastName: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
@@ -88,7 +89,8 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
name: data?.user?.name || "",
name: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
resolver: zodResolver(profileSchema),
});
@@ -102,7 +104,8 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
name: data?.user?.name || "",
name: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
{
keepValues: true,
@@ -127,6 +130,7 @@ export const ProfileForm = () => {
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
lastName: values.lastName || undefined,
});
await refetch();
toast.success("Profile Updated");
@@ -136,6 +140,7 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: "",
name: values.name || "",
lastName: values.lastName || "",
});
} catch (error) {
toast.error("Error updating the profile");
@@ -180,9 +185,22 @@ export const ProfileForm = () => {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -280,7 +298,7 @@ export const ProfileForm = () => {
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
<AvatarFallback className="rounded-lg">
{getFallbackAvatarInitials(
data?.user?.name,
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
)}
</AvatarFallback>
</Avatar>

View File

@@ -1,5 +1,7 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
</DropdownMenuItem>
</EditTraefikEnv>
<DropdownMenuItem
<DialogAction
title={
haveTraefikDashboardPortEnabled
? "Disable Traefik Dashboard"
: "Enable Traefik Dashboard"
}
description={
<div className="space-y-4">
<AlertBlock type="warning">
The Traefik container will be recreated from scratch. This
means the container will be deleted and created again, which
may cause downtime in your applications.
</AlertBlock>
<p>
Are you sure you want to{" "}
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
Traefik dashboard?
</p>
</div>
}
onClick={async () => {
await toggleDashboard({
enableDashboard: !haveTraefikDashboardPortEnabled,
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
);
refetchDashboard();
})
.catch(() => {});
.catch((error) => {
const errorMessage =
error?.message ||
"Failed to toggle dashboard. Please check if port 8080 is available.";
toast.error(errorMessage);
});
}}
className="w-full cursor-pointer space-x-3"
disabled={toggleDashboardIsLoading}
type="default"
>
<span>
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="w-full cursor-pointer space-x-3"
>
<span>
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
Dashboard
</span>
</DropdownMenuItem>
</DialogAction>
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}

View File

@@ -52,6 +52,7 @@ const Schema = z.object({
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
});
type Schema = z.infer<typeof Schema>;
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
serverId: serverId || "",
})
.then(async (_data) => {
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverType"
render={({ field }) => {
const serverTypeValue = form.watch("serverType");
return (
<FormItem>
<FormLabel>Server Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a server type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="deploy">Deploy Server</SelectItem>
<SelectItem value="build">Build Server</SelectItem>
<SelectLabel>Server Type</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
{serverTypeValue === "deploy" && (
<AlertBlock type="info" className="mt-2">
Deploy servers are used to run your applications,
databases, and services. They handle the deployment and
execution of your projects.
</AlertBlock>
)}
{serverTypeValue === "build" && (
<AlertBlock type="info" className="mt-2">
Build servers are dedicated to building your
applications. They handle the compilation and build
process, offloading this work from your deployment
servers. Build servers won't appear in deployment
options.
</AlertBlock>
)}
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshKeyId"

View File

@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const isBuildServer = server?.serverType === "build";
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
<TabsList
className={cn(
"grid w-[700px]",
isCloud ? "grid-cols-6" : "grid-cols-5",
isBuildServer
? "grid-cols-3"
: isCloud
? "grid-cols-6"
: "grid-cols-5",
)}
>
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{!isBuildServer && (
<>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
value="ssh-keys"
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
{!isBuildServer && (
<>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
</>
)}
</Tabs>
</div>
)}

View File

@@ -129,6 +129,9 @@ export const ShowServers = () => {
Status
</TableHead>
)}
<TableHead className="text-center">
Type
</TableHead>
<TableHead className="text-center">
IP Address
</TableHead>
@@ -153,6 +156,8 @@ export const ShowServers = () => {
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer =
server.serverType === "build";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
@@ -171,6 +176,15 @@ export const ShowServers = () => {
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge
variant={
isBuildServer ? "secondary" : "default"
}
>
{server.serverType}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
@@ -233,11 +247,12 @@ export const ShowServers = () => {
serverId={server.serverId}
/>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
@@ -286,41 +301,43 @@ export const ShowServers = () => {
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
)}
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
enabled: !!serverId,
},
);
const { data: server } = api.server.one.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const isBuildServer = server?.serverType === "build";
const _utils = api.useUtils();
return (
<CardContent className="p-0">
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
{isBuildServer
? "Shows the build server configuration status"
: "Shows the server configuration status"}
</p>
<div className="grid gap-2.5">
<StatusRow
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
{!isBuildServer && (
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
)}
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
{!isBuildServer && (
<>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
</>
)}
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
: "Not Created"
}
/>
<StatusRow
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");

View File

@@ -158,6 +158,7 @@ export const AddInvitation = () => {
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormDescription>

View File

@@ -0,0 +1,159 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const changeRoleSchema = z.object({
role: z.enum(["admin", "member"]),
});
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
interface Props {
memberId: string;
currentRole: "admin" | "member";
userEmail: string;
}
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
api.organization.updateMemberRole.useMutation();
const form = useForm<ChangeRoleSchema>({
defaultValues: {
role: currentRole,
},
resolver: zodResolver(changeRoleSchema),
});
useEffect(() => {
if (isOpen) {
form.reset({
role: currentRole,
});
}
}, [form, currentRole, isOpen]);
const onSubmit = async (data: ChangeRoleSchema) => {
await mutateAsync({
memberId,
role: data.role,
})
.then(async () => {
toast.success("Role updated successfully");
await utils.user.all.invalidate();
setIsOpen(false);
})
.catch((error) => {
toast.error(error?.message || "Error updating role");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Change Role
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>Change User Role</DialogTitle>
<DialogDescription>
Change the role for <strong>{userEmail}</strong>
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-change-role"
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-4"
>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
<FormDescription>
<strong>Admin:</strong> Can manage users and settings.
<br />
<strong>Member:</strong> Limited permissions, can be
customized.
<br />
<em className="text-muted-foreground text-xs">
Note: Owner role is intransferible.
</em>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-change-role"
type="submit"
>
Update Role
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -21,7 +21,6 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
@@ -30,12 +29,15 @@ import {
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { AddUserPermissions } from "./add-permissions";
import { ChangeRole } from "./change-role";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
const utils = api.useUtils();
const { data: session } = authClient.useSession();
return (
<div className="w-full">
@@ -68,7 +70,6 @@ export const ShowUsers = () => {
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>See all users</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
@@ -83,6 +84,52 @@ export const ShowUsers = () => {
</TableHeader>
<TableBody>
{data?.map((member) => {
const currentUserRole = data?.find(
(m) => m.user.id === session?.user?.id,
)?.role;
// Owner never has "Edit Permissions" (they're absolute owner)
// Other users can edit permissions if target is not themselves and target is a member
const canEditPermissions =
member.role !== "owner" &&
member.role === "member" &&
member.user.id !== session?.user?.id;
// Can change role based on hierarchy:
// - Owner: Can change anyone's role (except themselves and other owners)
// - Admin: Can only change member roles (not other admins or owners)
// - Owner role is intransferible
const canChangeRole =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
// Delete/Unlink follow same hierarchy as role changes
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
// - Admin: Can only delete/unlink members (not other admins or owner)
const canDelete =
member.role !== "owner" &&
!isCloud &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const canUnlink =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const hasAnyAction =
canEditPermissions ||
canChangeRole ||
canDelete ||
canUnlink;
return (
<TableRow key={member.id}>
<TableCell className="w-[100px]">
@@ -111,62 +158,73 @@ export const ShowUsers = () => {
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{hasAnyAction ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{member.role !== "owner" && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{canChangeRole && (
<ChangeRole
memberId={member.id}
currentRole={
member.role as "admin" | "member"
}
userEmail={member.user.email}
/>
)}
{member.role !== "owner" && (
<>
{!isCloud && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
{canEditPermissions && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{canDelete && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
.catch((err) => {
toast.error(
err?.message ||
"Error deleting user",
);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) =>
e.preventDefault()
}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
Delete User
</DropdownMenuItem>
</DialogAction>
)}
{canUnlink && (
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
@@ -180,8 +238,6 @@ export const ShowUsers = () => {
},
);
console.log(orgCount);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
@@ -227,10 +283,21 @@ export const ShowUsers = () => {
Unlink User
</DropdownMenuItem>
</DialogAction>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled
>
<span className="sr-only">
No actions available
</span>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</TableCell>
</TableRow>
);

View File

@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
domain: z.string(),
domain: z.string().trim().toLowerCase(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
@@ -49,7 +49,11 @@ const addServerDomain = z
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
if (
data.https &&
data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:

View File

@@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch {}
} catch (error) {
toast.error((error as Error).message || "Error updating Traefik ports");
}
};
return (
@@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
</p>
</div>
) : (
<ScrollArea className="h-[400px] pr-4">
<ScrollArea className="pr-4">
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id} className="bg-transparent">
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
<FormField
control={form.control}
name={`ports.${index}.targetPort`}
@@ -303,6 +305,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
</div>
</AlertBlock>
)}
<AlertBlock type="warning">
The Traefik container will be recreated from scratch. This
means the container will be deleted and created again, which
may cause downtime in your applications.
</AlertBlock>
</div>
<DialogFooter>
<Button

View File

@@ -158,7 +158,8 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
isEnabled: ({ isCloud, auth }) =>
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -168,7 +169,9 @@ const MENU: Menu = {
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToTraefikFiles) &&
!isCloud
),
},
@@ -179,7 +182,12 @@ const MENU: Menu = {
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -188,7 +196,12 @@ const MENU: Menu = {
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -197,7 +210,12 @@ const MENU: Menu = {
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
// Legacy unused menu, adjusted to the new structure
@@ -264,7 +282,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
@@ -278,7 +297,8 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -286,7 +306,8 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -295,14 +316,19 @@ const MENU: Menu = {
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
!!(
auth?.role === "owner" ||
auth?.canAccessToSSHKeys ||
auth?.role === "admin"
),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -311,7 +337,11 @@ const MENU: Menu = {
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
!!(
auth?.role === "owner" ||
auth?.canAccessToGitProviders ||
auth?.role === "admin"
),
},
{
isSingle: true,
@@ -319,7 +349,8 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -327,7 +358,8 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
@@ -336,7 +368,8 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -344,7 +377,8 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
@@ -352,7 +386,8 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -718,7 +753,9 @@ function SidebarLogo() {
</div>
);
})}
{(user?.role === "owner" || isCloud) && (
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -1082,7 +1119,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>

View File

@@ -49,7 +49,9 @@ export const UserNav = () => {
alt={data?.user?.image || ""}
/>
<AvatarFallback className="rounded-lg">
{getFallbackAvatarInitials(data?.user?.name)}
{getFallbackAvatarInitials(
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
)}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
@@ -102,7 +104,9 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToTraefikFiles) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -112,7 +116,9 @@ export const UserNav = () => {
Traefik
</DropdownMenuItem>
)}
{(data?.role === "owner" || data?.canAccessToDocker) && (
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToDocker) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -126,7 +132,7 @@ export const UserNav = () => {
)}
</>
) : (
data?.role === "owner" && (
(data?.role === "owner" || data?.role === "admin") && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {

View File

@@ -44,14 +44,20 @@ export function TimeBadge() {
.padStart(2, "0")}`;
};
const formattedTime = new Intl.DateTimeFormat("en-US", {
timeZone: serverTime.timezone,
timeStyle: "medium",
hour12: false,
}).format(time);
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)})
<div className="inline-flex items-center rounded-full border p-1 text-xs whitespace-nowrap max-w-full overflow-hidden gap-1">
<div className="inline-flex items-center px-1 gap-1">
<span className="hidden sm:inline">Server Time:</span>
<span className="font-medium tabular-nums">{formattedTime}</span>
</div>
<span className="hidden sm:inline text-primary/70 border rounded-full bg-foreground/5 px-1.5 py-0.5">
{serverTime.timezone} | {getUtcOffset(serverTime.timezone)}
</span>
</div>
);

View File

@@ -0,0 +1,9 @@
-- Fix inconsistent date formats in environment.createdAt field
-- Convert PostgreSQL timestamp format to ISO 8601 format
-- This addresses issue #2992 where old environments have PostgreSQL timestamp format
-- while new ones have ISO 8601 format
UPDATE "environment"
SET "createdAt" = to_char("createdAt"::timestamptz, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
WHERE "createdAt" NOT LIKE '%T%';

View File

@@ -0,0 +1,8 @@
CREATE TYPE "public"."serverType" AS ENUM('deploy', 'build');--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "serverType" "serverType" DEFAULT 'deploy' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "args" text[];

View File

@@ -0,0 +1 @@
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ALTER COLUMN "enableDockerCleanup" SET DEFAULT true;

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user" RENAME COLUMN "name" TO "firstName";--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "lastName" text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1,9 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
CREATE TABLE "custom" (
"customId" text PRIMARY KEY NOT NULL,
"endpoint" text NOT NULL,
"headers" jsonb
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "schedule" ADD COLUMN "timezone" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -848,6 +848,76 @@
"when": 1762632540024,
"tag": "0120_lame_captain_midlands",
"breakpoints": true
},
{
"idx": 121,
"version": "7",
"when": 1763755037033,
"tag": "0121_rainy_cargill",
"breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1764479387555,
"tag": "0122_absent_frightful_four",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1764525308939,
"tag": "0123_cloudy_piledriver",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1764571454170,
"tag": "0124_certain_cloak",
"breakpoints": true
},
{
"idx": 125,
"version": "7",
"when": 1764573207555,
"tag": "0125_neat_the_phantom",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1765065295708,
"tag": "0126_nifty_monster_badoon",
"breakpoints": true
},
{
"idx": 127,
"version": "7",
"when": 1765095189368,
"tag": "0127_superb_alice",
"breakpoints": true
},
{
"idx": 128,
"version": "7",
"when": 1765101709413,
"tag": "0128_hard_falcon",
"breakpoints": true
},
{
"idx": 129,
"version": "7",
"when": 1765136384035,
"tag": "0129_pale_roughhouse",
"breakpoints": true
},
{
"idx": 130,
"version": "7",
"when": 1765167657813,
"tag": "0130_perpetual_screwball",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,7 @@
import {
adminClient,
apiKeyClient,
inferAdditionalFields,
organizationClient,
twoFactorClient,
} from "better-auth/client/plugins";
@@ -13,5 +14,12 @@ export const authClient = createAuthClient({
twoFactorClient(),
apiKeyClient(),
adminClient(),
inferAdditionalFields({
user: {
lastName: {
type: "string",
},
},
}),
],
});

View File

@@ -6,9 +6,6 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.7",
"version": "v0.26.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -34,7 +34,8 @@
"docker:build:canary": "./docker/build.sh canary",
"docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts"
"test": "vitest --config __test__/vitest.config.ts",
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
@@ -98,6 +99,7 @@
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
@@ -112,14 +114,14 @@
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"yaml": "2.8.1",
"lodash": "4.17.21",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^15.3.2",
"next": "^16.0.7",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
@@ -153,10 +155,12 @@
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",

View File

@@ -4,9 +4,9 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import Script from "next/script";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
@@ -42,14 +42,6 @@ const MyApp = ({
<Head>
<title>Dokploy</title>
</Head>
{process.env.NEXT_PUBLIC_UMAMI_HOST &&
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
<Script
src={process.env.NEXT_PUBLIC_UMAMI_HOST}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
/>
)}
<ThemeProvider
attribute="class"
defaultTheme="system"
@@ -57,6 +49,7 @@ const MyApp = ({
disableTransitionOnChange
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}

View File

@@ -17,6 +17,7 @@ import { AddCommand } from "@/components/dashboard/application/advanced/general/
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -353,7 +354,7 @@ const Service = (
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />

View File

@@ -40,7 +40,7 @@ export async function getServerSideProps(
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
if (!user || (user.role !== "owner" && user.role !== "admin")) {
return {
redirect: {
permanent: true,

View File

@@ -30,7 +30,7 @@ export async function getServerSideProps(
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,

View File

@@ -18,7 +18,9 @@ const Page = () => {
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
{(data?.canAccessToAPI ||
data?.role === "owner" ||
data?.role === "admin") && <ShowApiKeys />}
{/* {isCloud && <RemoveSelfAccount />} */}
</div>

View File

@@ -27,7 +27,10 @@ import { api } from "@/utils/api";
const registerSchema = z
.object({
name: z.string().min(1, {
message: "Name is required",
message: "First name is required",
}),
lastName: z.string().min(1, {
message: "Last name is required",
}),
email: z
.string()
@@ -92,6 +95,7 @@ const Invitation = ({
const form = useForm<Register>({
defaultValues: {
name: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
@@ -115,6 +119,7 @@ const Invitation = ({
email: values.email,
password: values.password,
name: values.name,
lastName: values.lastName,
fetchOptions: {
headers: {
"x-dokploy-token": token,
@@ -197,12 +202,22 @@ const Invitation = ({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input
placeholder="Enter your name"
{...field}
/>
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -27,7 +27,10 @@ import { authClient } from "@/lib/auth-client";
const registerSchema = z
.object({
name: z.string().min(1, {
message: "Name is required",
message: "First name is required",
}),
lastName: z.string().min(1, {
message: "Last name is required",
}),
email: z
.string()
@@ -79,6 +82,7 @@ const Register = ({ isCloud }: Props) => {
const form = useForm<Register>({
defaultValues: {
name: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
@@ -95,6 +99,7 @@ const Register = ({ isCloud }: Props) => {
email: values.email,
password: values.password,
name: values.name,
lastName: values.lastName,
});
if (error) {
@@ -158,9 +163,22 @@ const Register = ({ isCloud }: Props) => {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="name" {...field} />
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -1,11 +1,11 @@
import { findAdmin } from "@dokploy/server";
import { findOwner } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => {
try {
const result = await findAdmin();
const result = await findOwner();
const update = await db
.update(user)

View File

@@ -1,4 +1,4 @@
import { findAdmin, generateRandomPassword } from "@dokploy/server";
import { findOwner, generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { account } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
try {
const randomPassword = await generateRandomPassword();
const result = await findAdmin();
const result = await findOwner();
const update = await db
.update(account)

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env tsx
/**
* Script to generate OpenAPI specification locally
* This runs in CI/CD to generate the openapi.json file
* which can then be consumed by the documentation website
*/
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { appRouter } from "../server/api/root";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function generateOpenAPI() {
try {
console.log("🔄 Generating OpenAPI specification...");
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "Dokploy API",
version: "1.0.0",
baseUrl: "https://your-dokploy-instance.com/api",
docsUrl: "https://docs.dokploy.com/api",
tags: [
"admin",
"docker",
"compose",
"registry",
"cluster",
"user",
"domain",
"destination",
"backup",
"deployment",
"mounts",
"certificates",
"settings",
"security",
"redirects",
"port",
"project",
"application",
"mysql",
"postgres",
"redis",
"mongo",
"mariadb",
"sshRouter",
"gitProvider",
"bitbucket",
"github",
"gitlab",
"gitea",
"server",
"swarm",
"ai",
"organization",
"schedule",
"rollback",
"volumeBackups",
"environment",
],
});
// Enhance metadata
openApiDocument.info = {
title: "Dokploy API",
description:
"Complete API documentation for Dokploy - Deploy applications, manage databases, and orchestrate your infrastructure. This API allows you to programmatically manage all aspects of your Dokploy instance.",
version: "1.0.0",
contact: {
name: "Dokploy Team",
url: "https://dokploy.com",
},
license: {
name: "Apache 2.0",
url: "https://github.com/dokploy/dokploy/blob/canary/LICENSE",
},
};
// Add security schemes
openApiDocument.components = {
...openApiDocument.components,
securitySchemes: {
apiKey: {
type: "apiKey",
in: "header",
name: "x-api-key",
description:
"API key authentication. Generate an API key from your Dokploy dashboard under Settings > API Keys.",
},
},
};
// Apply global security
openApiDocument.security = [
{
apiKey: [],
},
];
// Add external docs
openApiDocument.externalDocs = {
description: "Full documentation",
url: "https://docs.dokploy.com",
};
// Write to root of repo
const outputPath = resolve(__dirname, "../../../openapi.json");
writeFileSync(
outputPath,
JSON.stringify(openApiDocument, null, 2),
"utf-8",
);
console.log("✅ OpenAPI specification generated successfully!");
console.log(`📄 Output: ${outputPath}`);
console.log(
`📊 Endpoints: ${Object.keys(openApiDocument.paths || {}).length}`,
);
} catch (error) {
console.error("❌ Error generating OpenAPI specification:", error);
process.exit(1);
} finally {
process.exit(0);
}
}
generateOpenAPI();

View File

@@ -58,7 +58,11 @@ import {
applications,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
import {
cleanQueuesByApplication,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { uploadFileSchema } from "@/utils/schema";
@@ -725,7 +729,21 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("application", application.serverId);
}),
readTraefikConfig: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {

View File

@@ -59,7 +59,11 @@ import {
compose as composeTable,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import {
cleanQueuesByCompose,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
@@ -248,6 +252,21 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("compose", compose.serverId);
}),
loadServices: protectedProcedure
.input(apiFetchServices)

View File

@@ -47,15 +47,19 @@ export const destinationRouter = createTRPCRouter({
input;
try {
const rcloneFlags = [
`--s3-access-key-id=${accessKey}`,
`--s3-secret-access-key=${secretAccessKey}`,
`--s3-region=${region}`,
`--s3-endpoint=${endpoint}`,
`--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region="${region}"`,
`--s3-endpoint="${endpoint}"`,
"--s3-no-check-bucket",
"--s3-force-path-style",
"--retries 1",
"--low-level-retries 1",
"--timeout 10s",
"--contimeout 5s",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider=${provider}`);
rcloneFlags.unshift(`--s3-provider="${provider}"`);
}
const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;

View File

@@ -5,8 +5,8 @@ import {
createMount,
deployMariadb,
findBackupsByDbId,
findMariadbById,
findEnvironmentById,
findMariadbById,
findProjectById,
IS_CLOUD,
rebuildDatabase,

View File

@@ -5,8 +5,8 @@ import {
createMount,
deployMongo,
findBackupsByDbId,
findMongoById,
findEnvironmentById,
findMongoById,
findProjectById,
IS_CLOUD,
rebuildDatabase,

View File

@@ -1,4 +1,5 @@
import {
createCustomNotification,
createDiscordNotification,
createEmailNotification,
createGotifyNotification,
@@ -9,6 +10,7 @@ import {
findNotificationById,
IS_CLOUD,
removeNotificationById,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
@@ -17,6 +19,7 @@ import {
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
updateEmailNotification,
updateGotifyNotification,
@@ -36,6 +39,7 @@ import {
} from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateCustom,
apiCreateDiscord,
apiCreateEmail,
apiCreateGotify,
@@ -44,6 +48,7 @@ import {
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestGotifyConnection,
@@ -51,6 +56,7 @@ import {
apiTestNtfyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateGotify,
@@ -111,7 +117,7 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
@@ -228,7 +234,7 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
@@ -285,7 +291,7 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
@@ -334,6 +340,7 @@ export const notificationRouter = createTRPCRouter({
email: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
},
orderBy: desc(notifications.createdAt),
@@ -518,6 +525,59 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createCustom: adminProcedure
.input(apiCreateCustom)
.mutation(async ({ input, ctx }) => {
try {
return await createCustomNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateCustom: adminProcedure
.input(apiUpdateCustom)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (notification.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateCustomNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testCustomConnection: adminProcedure
.input(apiTestCustomConnection)
.mutation(async ({ input }) => {
try {
await sendCustomNotification(input, {
title: "Test Notification",
message: "Hi, From Dokploy 👋",
timestamp: new Date().toISOString(),
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createLark: adminProcedure
.input(apiCreateLark)
.mutation(async ({ input, ctx }) => {

View File

@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can create an organization",
@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -82,7 +80,22 @@ export const organizationRouter = createTRPCRouter({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ ctx, input }) => {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
return await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -96,12 +109,45 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can update the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
});
}
const result = await db
.update(organization)
.set({
@@ -119,12 +165,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
});
}
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -136,7 +177,27 @@ export const organizationRouter = createTRPCRouter({
});
}
if (org.ownerId !== ctx.user.id) {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can delete the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
@@ -194,6 +255,65 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
updateMemberRole: adminProcedure
.input(
z.object({
memberId: z.string(),
role: z.enum(["admin", "member"]),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch the target member
const target = await db.query.member.findFirst({
where: eq(member.id, input.memberId),
with: { user: true },
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
if (target.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to update this member's role",
});
}
// Prevent users from changing their own role
if (target.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot change your own role",
});
}
// Owner role is intransferible - cannot change to or from owner
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "The owner role is intransferible",
});
}
// Only owners can change admin roles
// Admins can only change member roles
if (ctx.user.role === "admin" && target.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can change admin roles. Admins can only modify member roles.",
});
}
// Update the target member's role
await db
.update(member)
.set({ role: input.role })
.where(eq(member.id, input.memberId));
return true;
}),
setDefault: protectedProcedure
.input(
z.object({

View File

@@ -8,6 +8,7 @@ import {
findEnvironmentById,
findPostgresById,
findProjectById,
getMountPath,
IS_CLOUD,
rebuildDatabase,
removePostgresById,
@@ -37,6 +38,7 @@ import {
postgres as postgresTable,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const postgresRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreatePostgres)
@@ -79,11 +81,13 @@ export const postgresRouter = createTRPCRouter({
);
}
const mountPath = getMountPath(input.dockerImage);
await createMount({
serviceId: newPostgres.postgresId,
serviceType: "postgres",
volumeName: `${newPostgres.appName}-data`,
mountPath: "/var/lib/postgresql/data",
mountPath: mountPath,
type: "volume",
});
@@ -282,12 +286,16 @@ export const postgresRouter = createTRPCRouter({
const backups = await findBackupsByDbId(input.postgresId, "postgres");
const cleanupOperations = [
removeService(postgres.appName, postgres.serverId),
cancelJobs(backups),
removePostgresById(input.postgresId),
async () => await removeService(postgres?.appName, postgres.serverId),
async () => await cancelJobs(backups),
async () => await removePostgresById(input.postgresId),
];
await Promise.allSettled(cleanupOperations);
for (const operation of cleanupOperations) {
try {
await operation();
} catch (_) {}
}
return postgres;
}),
@@ -363,6 +371,7 @@ export const postgresRouter = createTRPCRouter({
message: "You are not authorized to update this Postgres",
});
}
const service = await updatePostgresById(postgresId, {
...rest,
});

View File

@@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: newSchedule.scheduleId,
type: "schedule",
cronSchedule: newSchedule.cronExpression,
timezone: newSchedule.timezone,
});
} else {
scheduleJob(newSchedule);
@@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
timezone: updatedSchedule.timezone,
});
} else {
await removeJob({

View File

@@ -81,8 +81,10 @@ export const serverRouter = createTRPCRouter({
}),
getDefaultCommand: protectedProcedure
.input(apiFindOneServer)
.query(async () => {
return defaultCommand();
.query(async ({ input }) => {
const server = await findServerById(input.serverId);
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
all: protectedProcedure.query(async ({ ctx }) => {
const result = await db
@@ -124,10 +126,30 @@ export const serverRouter = createTRPCRouter({
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "deploy"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "deploy"),
),
});
return result;
}),
buildServers: protectedProcedure.query(async ({ ctx }) => {
const result = await db.query.server.findMany({
orderBy: desc(server.createdAt),
where: IS_CLOUD
? and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "build"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "build"),
),
});
return result;

View File

@@ -1,11 +1,13 @@
import {
canAccessToTraefikFiles,
checkGPUStatus,
cleanStoppedContainers,
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
cleanUpUnusedVolumes,
checkPortInUse,
cleanupAll,
cleanupBuilders,
cleanupContainers,
cleanupImages,
cleanupSystem,
cleanupVolumes,
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
let newPorts = ports;
// If receive true, add 8080 to ports
if (input.enableDashboard) {
// Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) {
const conflictingContainer = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({
targetPort: 8080,
publishedPort: 8080,
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
cleanUnusedImages: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanupImages(input?.serverId);
return true;
}),
cleanUnusedVolumes: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedVolumes(input?.serverId);
await cleanupVolumes(input?.serverId);
return true;
}),
cleanStoppedContainers: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanStoppedContainers(input?.serverId);
await cleanupContainers(input?.serverId);
return true;
}),
cleanDockerBuilder: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
await cleanupBuilders(input?.serverId);
}),
cleanDockerPrune: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanupSystem(input?.serverId);
await cleanupBuilders(input?.serverId);
return true;
}),
cleanAll: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanStoppedContainers(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
await cleanupAll(input?.serverId);
return true;
}),
@@ -201,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: input.sshPrivateKey,
});
@@ -213,11 +223,9 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
const user = await updateUser(ctx.user.id, {
const user = await updateUser(ctx.user.ownerId, {
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
}),
letsEncryptEmail: input.letsEncryptEmail,
certificateType: input.certificateType,
https: input.https,
});
@@ -240,7 +248,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: null,
});
return true;
@@ -281,9 +289,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages(server.serverId);
await cleanUpDockerBuilder(server.serverId);
await cleanUpSystemPrune(server.serverId);
await cleanupAll(server.serverId);
await sendDockerCleanupNotifications(server.organizationId);
});
}
@@ -300,7 +308,7 @@ export const settingsRouter = createTRPCRouter({
}
}
} else if (!IS_CLOUD) {
const userUpdated = await updateUser(ctx.user.id, {
const userUpdated = await updateUser(ctx.user.ownerId, {
enableDockerCleanup: input.enableDockerCleanup,
});
@@ -309,9 +317,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await cleanupAll();
await sendDockerCleanupNotifications(
ctx.session.activeOrganizationId,
);
@@ -587,7 +595,7 @@ export const settingsRouter = createTRPCRouter({
return ports.some((port) => port.targetPort === 8080);
}),
readStatsLogs: adminProcedure
readStatsLogs: protectedProcedure
.meta({
openapi: {
path: "/read-stats-logs",
@@ -650,7 +658,7 @@ export const settingsRouter = createTRPCRouter({
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
return processedLogs || [];
}),
haveActivateRequests: adminProcedure.query(async () => {
haveActivateRequests: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
@@ -665,7 +673,7 @@ export const settingsRouter = createTRPCRouter({
return !!parsedConfig?.accessLog?.filePath;
}),
toggleRequests: adminProcedure
toggleRequests: protectedProcedure
.input(
z.object({
enable: z.boolean(),
@@ -810,6 +818,19 @@ export const settingsRouter = createTRPCRouter({
"dokploy-traefik",
input?.serverId,
);
for (const port of input.additionalPorts) {
const portCheck = await checkPortInUse(
port.publishedPort,
input.serverId,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
const preparedEnv = prepareEnvironmentVariables(env);
await writeTraefikSetup({
@@ -835,7 +856,7 @@ export const settingsRouter = createTRPCRouter({
const ports = await readPorts("dokploy-traefik", input?.serverId);
return ports;
}),
updateLogCleanup: adminProcedure
updateLogCleanup: protectedProcedure
.input(
z.object({
cronExpression: z.string().nullable(),
@@ -851,7 +872,7 @@ export const settingsRouter = createTRPCRouter({
return stopLogCleanup();
}),
getLogCleanupStatus: adminProcedure.query(async () => {
getLogCleanupStatus: protectedProcedure.query(async () => {
return getLogCleanupStatus();
}),

View File

@@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({
});
const items = getStripeItems(input.serverQuantity, input.isAnnual);
const user = await findUserById(ctx.user.id);
// Always operate on the organization owner's Stripe customer
const owner = await findUserById(ctx.user.ownerId);
let stripeCustomerId = user.stripeCustomerId;
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
await updateUser(user.id, {
await updateUser(owner.id, {
stripeCustomerId: null,
});
stripeCustomerId = null;
@@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({
customer: stripeCustomerId,
}),
metadata: {
adminId: user.id,
adminId: owner.id,
},
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
@@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({
return { sessionId: session.id };
}),
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
const user = await findUserById(ctx.user.id);
// Use the organization's owner account for billing portal
const owner = await findUserById(ctx.user.ownerId);
if (!user.stripeCustomerId) {
if (!owner.stripeCustomerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Stripe Customer ID not found",
});
}
const stripeCustomerId = user.stripeCustomerId;
const stripeCustomerId = owner.stripeCustomerId;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",

View File

@@ -1,6 +1,5 @@
import {
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
@@ -87,7 +86,11 @@ export const userRouter = createTRPCRouter({
// Allow access if:
// 1. User is requesting their own information
// 2. User has owner role (admin permissions) AND user is in the same organization
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
if (
memberResult.userId !== ctx.user.id &&
ctx.user.role !== "owner" &&
ctx.user.role !== "admin"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
@@ -223,10 +226,61 @@ export const userRouter = createTRPCRouter({
userId: z.string(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
// Ensure the acting user has admin privileges in the active organization
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners or admins can delete users",
});
}
// Fetch target member within the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Target user is not a member of this organization",
});
}
// Never allow deleting the organization owner via this endpoint
if (targetMember.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot delete the organization owner",
});
}
// Admin self-protection: an admin cannot delete themselves
if (targetMember.role === "admin" && input.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Admins cannot delete themselves. Ask the owner or another admin.",
});
}
// Only owners can delete admins
// Admins can only delete members
if (ctx.user.role === "admin" && targetMember.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can delete admins. Admins can only delete members.",
});
}
return await removeUserById(input.userId);
}),
assignPermissions: adminProcedure
@@ -376,6 +430,23 @@ export const userRouter = createTRPCRouter({
createApiKey: protectedProcedure
.input(apiCreateApiKey)
.mutation(async ({ input, ctx }) => {
// Verify user is a member of the organization specified in metadata
if (input.metadata?.organizationId) {
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.metadata.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
}
const apiKey = await createApiKey(ctx.user.id, input);
return apiKey;
}),
@@ -386,7 +457,35 @@ export const userRouter = createTRPCRouter({
userId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
// Users can check their own organizations
// Admins and owners can check organizations of members in their active organization
if (input.userId !== ctx.user.id) {
// Verify the target user is a member of the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User is not a member of your active organization",
});
}
// Only admins and owners can check other users' organizations
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only admins and owners can check other users' organizations",
});
}
}
const organizations = await db.query.member.findMany({
where: eq(member.userId, input.userId),
});

View File

@@ -183,7 +183,11 @@ export const uploadProcedure = async (opts: any) => {
};
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@@ -197,7 +201,11 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({

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