Compare commits

...

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

View File

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

View File

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

View File

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

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

View File

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

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,10 +11,18 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,
appName: "",
autoDeploy: true,
enableSubmodules: false,

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

@@ -122,6 +122,22 @@ const NetworkSwarmSchema = z.array(
const LabelsSwarmSchema = z.record(z.string());
const EndpointPortConfigSwarmSchema = z
.object({
Protocol: z.string().optional(),
TargetPort: z.number().optional(),
PublishedPort: z.number().optional(),
PublishMode: z.string().optional(),
})
.strict();
const EndpointSpecSwarmSchema = z
.object({
Mode: z.string().optional(),
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
})
.strict();
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
return z
.string()
@@ -178,6 +194,9 @@ const addSwarmSettings = z.object({
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: createStringToJSONSchema(
EndpointSpecSwarmSchema,
).nullable(),
});
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
@@ -234,6 +253,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
labelsSwarm: null,
networkSwarm: null,
stopGracePeriodSwarm: null,
endpointSpecSwarm: null,
},
resolver: zodResolver(addSwarmSettings),
});
@@ -275,6 +295,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
? JSON.stringify(data.networkSwarm, null, 2)
: null,
stopGracePeriodSwarm: normalizedStopGracePeriod,
endpointSpecSwarm: data.endpointSpecSwarm
? JSON.stringify(data.endpointSpecSwarm, null, 2)
: null,
});
}
}, [form, form.reset, data]);
@@ -296,6 +319,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
endpointSpecSwarm: data.endpointSpecSwarm,
})
.then(async () => {
toast.success("Swarm settings updated");
@@ -846,6 +870,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpointSpecSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Endpoint Spec</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Mode?: string | undefined;
Ports?: Array<{
Protocol?: string | undefined;
TargetPort?: number | undefined;
PublishedPort?: number | undefined;
PublishMode?: string | undefined;
}> | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Mode": "dnsrr",
"Ports": [
{
"Protocol": "tcp",
"TargetPort": 5432,
"PublishedPort": 5432,
"PublishMode": "host"
}
]
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
<Button
isLoading={isLoading}

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals.
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import _ from "lodash";
import {
CheckIcon,
ChevronsUpDown,
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = debounce((value: string) => {
const debouncedSetSearch = _.debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 350);

View File

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

View File

@@ -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,10 +10,12 @@ 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";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
@@ -44,7 +46,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -52,16 +53,25 @@ import {
SelectTrigger,
SelectValue,
} 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";
@@ -73,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
@@ -128,13 +165,18 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, searchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy]);
return (
<>
<BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
@@ -148,7 +190,6 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className="">
<HandleProject />
@@ -298,7 +339,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
@@ -340,7 +387,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}

View File

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

@@ -1,4 +1,3 @@
import type { findEnvironmentById } from "@dokploy/server/index";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -27,12 +26,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { api, type RouterOutputs } from "@/utils/api";
type Environment = Omit<
Awaited<ReturnType<typeof findEnvironmentById>>,
"project"
>;
type Project = RouterOutputs["project"]["all"][number];
type Environment = Project["environments"][number];
export type Services = {
appName: string;
@@ -53,17 +50,16 @@ export type Services = {
};
export const extractServices = (data: Environment | undefined) => {
const applications: Services[] =
data?.applications.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const applications: Services[] = (data?.applications?.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
@@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => {
serverId: item.serverId,
})) || [];
const compose: Services[] =
data?.compose.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const compose: Services[] = (data?.compose?.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
applications.push(
...mysql,

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

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

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

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

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/utils/api";
export function TimeBadge() {
const { data: serverTime } = api.server.getServerTime.useQuery(undefined);
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
if (serverTime?.time) {
setTime(new Date(serverTime.time));
}
}, [serverTime]);
useEffect(() => {
const timer = setInterval(() => {
setTime((prevTime) => {
if (!prevTime) return null;
const newTime = new Date(prevTime.getTime() + 1000);
return newTime;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
if (!time || !serverTime?.timezone) {
return null;
}
const getUtcOffset = (timeZone: string) => {
const date = new Date();
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
const sign = offset >= 0 ? "+" : "-";
const hours = Math.floor(Math.abs(offset));
const minutes = (Math.abs(offset) * 60) % 60;
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
};
const formattedTime = new Intl.DateTimeFormat("en-US", {
timeZone: serverTime.timezone,
timeStyle: "medium",
hour12: false,
}).format(time);
return (
<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 @@
ALTER TABLE "member" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -834,6 +834,90 @@
"when": 1761415824484,
"tag": "0118_loose_anita_blake",
"breakpoints": true
},
{
"idx": 119,
"version": "7",
"when": 1762142756443,
"tag": "0119_bouncy_morbius",
"breakpoints": true
},
{
"idx": 120,
"version": "7",
"when": 1762632540024,
"tag": "0120_lame_captain_midlands",
"breakpoints": true
},
{
"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.6",
"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,15 +114,15 @@
"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",
"node-os-utils": "1.3.7",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
@@ -153,17 +155,18 @@
"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",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",

View File

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

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

View File

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

View File

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

View File

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

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