Compare commits

...

180 Commits

Author SHA1 Message Date
Mauricio Siu
85632fd0c2 Merge pull request #3219 from Dokploy/feat/add-registry-url-only-allow-hostname
Feat/add registry url only allow hostname
2025-12-10 00:34:27 -06:00
Mauricio Siu
31cdae1b72 feat(registry): improve server selection guidance in registry settings
- Enhanced the user interface for server selection in the registry settings, providing clearer instructions based on whether the server is cloud-based or not.
- Added conditional messaging to inform users about authentication processes related to the selected server, improving overall user experience.
2025-12-10 00:31:03 -06:00
Mauricio Siu
702af64444 feat(registry): enhance registry URL validation and update handling
- Made the registry URL field optional and added refined validation to ensure only valid hostnames are accepted, excluding protocols and paths.
- Updated the handling of registry URL in the form to default to an empty string if not provided.
- Added descriptive guidance in the form to assist users in entering the correct format for the registry URL.
2025-12-10 00:25:23 -06:00
Mauricio Siu
eef27b67c2 Merge pull request #3218 from Dokploy/feat/add-baner-https-not-available-traefik.me
feat(domains): add support for traefik.me domain notifications
2025-12-10 00:18:58 -06:00
Mauricio Siu
70f50dd8bc refactor(preview-deployments): remove warning alert for traefik.me domains
- Eliminated the alert block that notified users about the lack of HTTPS support for traefik.me domains, streamlining the user interface in the AddPreviewDomain component.
2025-12-10 00:18:50 -06:00
Mauricio Siu
3e25b97b99 test(environment): add isDefault flag to environment tests
- Updated test cases for the environment structure to include the new `isDefault` boolean flag.
- Ensured consistency in the environment schema across different test files, enhancing test coverage for environment-related functionalities.
2025-12-10 00:18:18 -06:00
Mauricio Siu
22927c2716 feat(domains): add support for traefik.me domain notifications
- Implemented checks for traefik.me domains across AddDomain, AddPreviewDomain, and ShowPreviewSettings components.
- Added informational alerts to notify users that traefik.me is a public HTTP service and does not support SSL/HTTPS, ensuring clarity in domain configuration.
2025-12-10 00:17:18 -06:00
Mauricio Siu
8ab4ee8e0e Merge pull request #3217 from Dokploy/3216-production-environment-created-by-default-and-cant-be-removed-or-renamed
feat(environment): introduce isDefault flag for environments
2025-12-10 00:10:42 -06:00
Mauricio Siu
99aa34f27e feat(environment): introduce isDefault flag for environments
- Added `isDefault` boolean column to the environment schema, defaulting to false.
- Updated environment creation and deletion logic to handle default environments, allowing the production environment to be created and renamed.
- Enhanced error handling for environment updates and deletions to prevent modifications to default environments.
- Updated UI components to reflect changes in environment selection based on the new default logic.
2025-12-10 00:10:05 -06:00
Mauricio Siu
48be8544cf Merge pull request #3215 from Dokploy/3198-bug-docker-swarm-deployment-fails-due-to-duplicate-username-in-image-tag
test(upload): add unit tests for getRegistryTag function
2025-12-09 23:56:28 -06:00
Mauricio Siu
ee411ac74f test(upload): add unit tests for getRegistryTag function
- Introduced a new test suite for the getRegistryTag function, covering various scenarios including handling of usernames, image prefixes, and custom registry URLs.
- Ensured that the function correctly constructs image tags based on different input conditions, improving test coverage and reliability.
2025-12-09 23:54:54 -06:00
Mauricio Siu
c233ddb520 Merge pull request #3214 from Dokploy/3197-requests-page-started-showing-my-own-dashboard-requests
3197 requests page started showing my own dashboard requests
2025-12-09 23:17:06 -06:00
autofix-ci[bot]
0cfe87cb72 [autofix.ci] apply automated fixes 2025-12-10 05:16:25 +00:00
Mauricio Siu
7998b296a2 feat(logs): filter out Dokploy dashboard requests from logs processing
- Added a test case to ensure Dokploy dashboard requests are filtered out correctly.
- Updated the logs processing logic to exclude both Dokploy service app and dashboard requests, improving log clarity and relevance.
2025-12-09 23:16:04 -06:00
Mauricio Siu
9e20f66bf5 Merge branch 'canary' into 3197-requests-page-started-showing-my-own-dashboard-requests 2025-12-09 23:09:24 -06:00
Mauricio Siu
1dc943ef5b Merge pull request #3213 from Dokploy/3086-webhook-deployment-fails-with-webhook-docker-image-name-not-found-after-v02510-update
refactor(deploy): streamline webhook image validation logic
2025-12-09 23:08:38 -06:00
Mauricio Siu
0f63fdac4e refactor(deploy): streamline webhook image validation logic
- Simplified the validation process for webhook image and Docker tag by consolidating checks and maintaining backward compatibility.
- If webhook image information is not provided, the system now defaults to using the configured image, preserving previous behavior.
2025-12-09 23:07:44 -06:00
Mauricio Siu
ec8c516aa3 Merge pull request #3212 from Dokploy/feat/add-create-env-file-flag
feat(environment): add createEnvFile option to environment settings
2025-12-09 23:01:56 -06:00
Mauricio Siu
58be8f91c0 test(drop, traefik): enable createEnvFile option in test configurations
- Updated test configurations for both drop and traefik to include the new `createEnvFile` option, ensuring that tests reflect the latest environment settings.
2025-12-09 23:00:08 -06:00
autofix-ci[bot]
2036ac3dc8 [autofix.ci] apply automated fixes 2025-12-10 04:59:44 +00:00
Mauricio Siu
17f83f746a feat(environment): add createEnvFile option to environment settings
- Introduced a new boolean field `createEnvFile` in the environment schema to control the generation of an .env file during the build process.
- Updated the form in the dashboard to include a toggle for `createEnvFile`, allowing users to enable or disable this feature.
- Modified the Docker command generation logic to respect the `createEnvFile` flag, ensuring that the environment file is only created when appropriate.
- Updated the database schema to include the `createEnvFile` column in the application table with a default value of true.
2025-12-09 22:59:04 -06:00
Mauricio Siu
bcd1cbe920 chore(package): bump version to v0.26.1 2025-12-09 22:50:19 -06:00
Mauricio Siu
3993263615 Merge pull request #3210 from Dokploy/3208-dokploy-fails-to-start-cannot-read-properties-of-null-reading-enabledockercleanup-v0260
fix(backups): enhance admin check to ensure user existence
2025-12-09 17:07:22 -06:00
Mauricio Siu
97bd4de4f1 fix(backups): enhance admin check to ensure user existence
- Updated the admin verification logic to check for both admin presence and user existence before proceeding with backup initialization.
2025-12-09 17:06:57 -06:00
Mauricio Siu
2fc29ff7c8 feat(logging): exclude Dashboard requests from access logs processing
- Updated the log processing functions to filter out requests that start with "/dashboard".
- Enhanced the monitoring configuration to also exclude Dashboard requests alongside the Dokploy service app.
2025-12-09 17:06:11 -06:00
Mauricio Siu
4a74016b52 feat(dashboard): replace Chatwoot widget with HubSpot widget in dashboard layout
- Updated the dashboard layout to use the HubSpotWidget instead of ChatwootWidget.
- Added a new HubSpotWidget component that loads the HubSpot script asynchronously.
2025-12-09 16:46:29 -06:00
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
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
фырат ёздэн
0fbb063d06 chore: updated dockerSafeExec() logs 2025-11-30 22:15:01 +03:00
фырат ёздэн
56d4e61c1f fix: removed .ts ext 2025-11-30 21:51:56 +03:00
фырат ёздэн
7ce36a50e8 chore: edited dockerSafeExec() description 2025-11-30 21:35:05 +03: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
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
фырат ёздэн
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
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
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
Jordan B
5890b321b2 fix: parse CPU value for progress component in monitoring dashboard 2025-11-21 13:38:09 +01:00
Mauricio Siu
d02913d69e Merge branch 'canary' into multiple-admins 2025-11-13 22:40:49 -06:00
HarikrishnanD
c459997453 fix(traefik): validate port 8080 before enabling dashboard 2025-11-13 11:52:06 +05:30
ChristoferMendes
1c0673b327 feat: update server package with dbml script runner 2025-11-11 09:16:45 -03:00
ChristoferMendes
334d9c91ef Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-11-11 09:11:38 -03:00
HarikrishnanD
615d89ee0c feat(requests): conditionally render traefik reload warning 2025-11-07 11:40:30 +05:30
ChristoferMendes
0c24507872 chore: run format-and-lint:fix 2025-11-03 09:44:14 -03:00
ChristoferMendes
d2cd01aff7 chore: run dbml.ts script to update schema.dbml 2025-11-03 09:41:45 -03:00
ChristoferMendes
6349cabf27 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-11-03 09:35:37 -03:00
ChristoferMendes
94536ab05a Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-31 11:48:35 -03:00
autofix-ci[bot]
8e5be8dbcb [autofix.ci] apply automated fixes 2025-10-23 12:00:30 +00:00
HarikrishnanD
046606e496 feat: add volume backup notification support (#2875) 2025-10-23 17:28:24 +05:30
ChristoferMendes
e9cf1f4caa Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-21 14:35:58 -03:00
ChristoferMendes
ee0a299343 Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider 2025-10-09 11:08:07 -03:00
NeoIsRecursive
1b77c8029b wip 2025-10-04 10:56:53 +02:00
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
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
134 changed files with 61712 additions and 969 deletions

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

@@ -0,0 +1,209 @@
import type { Registry } from "@dokploy/server";
import { getRegistryTag } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("getRegistryTag", () => {
// Helper to create a mock registry
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
return {
registryId: "test-registry-id",
registryName: "Test Registry",
username: "myuser",
password: "test-password",
registryUrl: "docker.io",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "test-org-id",
...overrides,
};
};
describe("with username (no imagePrefix)", () => {
it("should handle simple image name without tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myuser/nginx:latest");
});
it("should handle image name with username already present (no duplication)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with username and tag already present", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
});
it("should handle complex image name with username", () => {
const registry = createMockRegistry({ username: "siumauricio" });
const result = getRegistryTag(
registry,
"siumauricio/app-parse-multi-byte-port-e32uh7",
);
// Should not duplicate username
expect(result).toBe(
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
);
});
it("should handle image name with different username (should not duplicate)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with full registry URL (no username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "docker.io/nginx");
// Should add username since imageName doesn't have one
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with custom registry URL and username", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
// Should not duplicate username even if registry URL is different
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with custom registry URL (different username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
// Should use registry username, not the one in imageName
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("with imagePrefix", () => {
it("should use imagePrefix instead of username", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myorg/nginx");
});
it("should use imagePrefix with image tag", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myorg/nginx:latest");
});
it("should handle imagePrefix with username already in image name", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
it("should handle imagePrefix matching image name prefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myorg/myprivaterepo");
// Should not duplicate prefix
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
});
describe("without registryUrl", () => {
it("should work without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myuser/nginx");
});
it("should work without registryUrl with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myorg/nginx");
});
it("should handle username already present without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("myuser/myprivaterepo");
});
});
describe("with custom registryUrl", () => {
it("should handle custom registry URL", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myuser/nginx");
});
it("should handle custom registry URL with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myorg/nginx");
});
it("should handle custom registry URL with username already present", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
});
});
describe("edge cases", () => {
it("should handle empty image name", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "");
expect(result).toBe("docker.io/myuser/");
});
it("should handle image name with multiple slashes", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/suborg/repo");
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with username at different position", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/myuser/repo");
expect(result).toBe("docker.io/myuser/repo");
});
});
});

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

@@ -189,7 +189,7 @@ describe("deployApplication - Command Generation Tests", () => {
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).mockReturnValue(mockNixpacksCommand);
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
@@ -220,7 +220,7 @@ describe("deployApplication - Command Generation Tests", () => {
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
@@ -241,7 +241,7 @@ describe("deployApplication - Command Generation Tests", () => {
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
@@ -260,7 +260,7 @@ describe("deployApplication - Command Generation Tests", () => {
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",

View File

@@ -28,6 +28,7 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
createEnvFile: true,
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
@@ -41,6 +42,9 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
@@ -64,6 +68,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",

View File

@@ -54,4 +54,22 @@ describe("processLogs", () => {
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
// Test with only Dokploy dashboard entry - should be filtered out
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
expect(resultOnlyDokploy.data).toHaveLength(0);
expect(resultOnlyDokploy.totalCount).toBe(0);
// Test with mixed entries - Dokploy should be filtered, others should remain
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
const resultMixed = parseRawConfig(mixedEntries);
expect(resultMixed.data).toHaveLength(1);
expect(resultMixed.totalCount).toBe(1);
expect(resultMixed.data[0]?.ServiceName).not.toBe(
"dokploy-service-app@file",
);
});
});

View File

@@ -1,7 +1,6 @@
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 = {
TaskTemplate?: {

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

@@ -7,6 +7,7 @@ const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
previewLabels: [],
createEnvFile: true,
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
@@ -17,6 +18,9 @@ const baseApp: ApplicationNested = {
giteaBuildPath: "",
giteaId: "",
args: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,
@@ -46,6 +50,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",

View File

@@ -143,7 +143,7 @@ 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} />
)}
@@ -373,7 +373,19 @@ export const ShowDeployments = ({
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
description={
<div className="flex flex-col gap-3">
<p>
Are you sure you want to rollback to this
deployment?
</p>
<AlertBlock type="info" className="text-sm">
Please wait a few seconds while the image is
pulled from the registry. Your application
should be running shortly.
</AlertBlock>
</div>
}
type="default"
onClick={async () => {
await rollback({

View File

@@ -208,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const certificateType = form.watch("certificateType");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -502,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>

View File

@@ -5,14 +5,23 @@ import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: "",
buildArgs: "",
buildSecrets: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
useEffect(() => {
if (data) {
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
createEnvFile: formData.createEnvFile,
applicationId,
})
.then(async () => {
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
createEnvFile: data?.createEnvFile ?? true,
});
};
@@ -167,6 +182,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created during the
build process. Disable this if you don't want to generate
an environment file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>

View File

@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
resolver: zodResolver(domain),
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
form.reset({
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
name="host"
render={({ field }) => (
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>

View File

@@ -4,6 +4,7 @@ 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
});
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 {
@@ -109,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
toast.error(
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -130,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
toast.error(
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -147,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
// Redirect to first available environment if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments?.find(
(env) => env.name === "production",
const firstEnv = environments?.find(
(env) => env.environmentId !== selectedEnvironment.environmentId,
);
if (productionEnv) {
if (firstEnv) {
router.push(
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
);
} else {
// No other environments, redirect to project page
router.push(`/dashboard/project/${projectId}`);
}
}
} catch (error) {
@@ -246,22 +246,8 @@ 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">
<div className="flex items-center gap-1 px-2">
{!environment.isDefault && (
<Button
variant="ghost"
size="sm"
@@ -273,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
>
<PencilIcon className="h-3 w-3" />
</Button>
{canDeleteEnvironments && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
)}
)}
{canDeleteEnvironments && !environment.isDefault && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})}

View File

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

View File

@@ -65,6 +65,25 @@ export const ShowRequests = () => {
to: Date | 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) {
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
@@ -85,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">

View File

@@ -89,24 +89,26 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
// Find default environment, or fall back to first environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,
) || project?.environments?.[0];
if (!productionEnvironment) return null;
if (!defaultEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
{project.name} / {defaultEnvironment.name}
</CommandItem>
);
})}

View File

@@ -45,7 +45,34 @@ const AddRegistrySchema = z.object({
password: z.string().min(1, {
message: "Password is required",
}),
registryUrl: z.string(),
registryUrl: z
.string()
.optional()
.refine(
(val) => {
// If empty or undefined, skip validation (field is optional)
if (!val || val.trim().length === 0) {
return true;
}
// Validate that it's a valid hostname (no protocol, no path, optional port)
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
// Invalid: https://example.com, example.com/path
const trimmed = val.trim();
// Check for protocol or path - these are not allowed
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
return false;
}
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
// Allow optional port at the end
const hostnameRegex =
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
return hostnameRegex.test(trimmed);
},
{
message:
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
},
),
imagePrefix: z.string(),
serverId: z.string().optional(),
});
@@ -99,6 +126,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
const selectedServer = servers?.find(
(server) => server.serverId === serverId,
);
useEffect(() => {
if (registry) {
@@ -125,7 +155,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
password: data.password,
registryName: data.registryName,
username: data.username,
registryUrl: data.registryUrl,
registryUrl: data.registryUrl || "",
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
@@ -261,6 +291,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Registry URL</FormLabel>
<FormDescription>
Enter only the hostname (e.g.,
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
</FormDescription>
<FormControl>
<Input
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
@@ -282,8 +316,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
<FormItem>
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
<FormDescription>
Select a server to test the registry. this will run the
following command on the server
{!isCloud ? (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Choose where to authenticate with the registry. By
default, authentication occurs on the Dokploy
server. Select a specific server to authenticate
from that server instead.
</>
)}
</>
) : (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Select a server to authenticate with the registry.
The authentication will be performed from the
selected server.
</>
)}
</>
)}
</FormDescription>
<FormControl>
<Select
@@ -345,7 +411,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryUrl: registryUrl || "",
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,

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),
@@ -107,6 +114,21 @@ export const notificationSchema = z.discriminatedUnion("type", [
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,6 +349,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken || "",
topic: notification.ntfy?.topic,
@@ -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,6 +506,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken || "",
topic: data.topic,
@@ -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) {
@@ -1043,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
@@ -1134,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"
@@ -1215,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark
isLoadingLark ||
isLoadingCustom
}
variant="secondary"
type="button"
@@ -1269,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
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 (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

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

View File

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

View File

@@ -29,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">
@@ -81,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]">
@@ -109,7 +158,7 @@ export const ShowUsers = () => {
</TableCell>
<TableCell className="text-right flex justify-end">
{member.role !== "owner" && (
{hasAnyAction ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -127,11 +176,23 @@ export const ShowUsers = () => {
Actions
</DropdownMenuLabel>
<AddUserPermissions
userId={member.user.id}
/>
{canChangeRole && (
<ChangeRole
memberId={member.id}
currentRole={
member.role as "admin" | "member"
}
userEmail={member.user.email}
/>
)}
{!isCloud && (
{canEditPermissions && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{canDelete && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
@@ -146,9 +207,10 @@ export const ShowUsers = () => {
);
refetch();
})
.catch(() => {
.catch((err) => {
toast.error(
"Error deleting destination",
err?.message ||
"Error deleting user",
);
});
}}
@@ -162,66 +224,79 @@ export const ShowUsers = () => {
</DialogAction>
)}
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
onClick={async () => {
if (!isCloud) {
const orgCount =
await utils.user.checkUserOrganizations.fetch(
{
{canUnlink && (
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
onClick={async () => {
if (!isCloud) {
const orgCount =
await utils.user.checkUserOrganizations.fetch(
{
userId: member.user.id,
},
);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting user",
);
});
return;
}
}
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
console.log(orgCount);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting user",
);
});
return;
if (!error) {
toast.success(
"User unlinked successfully",
);
refetch();
} else {
toast.error(
"Error unlinking user",
);
}
}
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
if (!error) {
toast.success(
"User unlinked successfully",
);
refetch();
} else {
toast.error("Error unlinking user");
}
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
}}
>
Unlink User
</DropdownMenuItem>
</DialogAction>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Unlink User
</DropdownMenuItem>
</DialogAction>
)}
</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

@@ -1,6 +1,6 @@
import { api } from "@/utils/api";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { ChatwootWidget } from "../shared/ChatwootWidget";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
interface Props {
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
<>
<Page>{children}</Page>
{isCloud === true && isUserSubscribed === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};

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;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
-- Set isDefault to true for existing production environments
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';

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

@@ -876,6 +876,62 @@
"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
},
{
"idx": 131,
"version": "7",
"when": 1765342621312,
"tag": "0131_volatile_beast",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1765346573500,
"tag": "0132_clean_layla_miller",
"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.11",
"version": "v0.26.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -118,7 +118,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^15.3.2",
"next": "^16.0.7",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",

View File

@@ -4,7 +4,6 @@ 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";
@@ -43,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"

View File

@@ -81,41 +81,34 @@ export default async function handler(
return;
}
if (!webhookImageName) {
res.status(301).json({
message: "Webhook Docker Image Name Not Found",
});
return;
}
// If webhook provides image information, validate it matches the configured image
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
if (webhookImageName) {
// 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;
}
// 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 (!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}).`,
});
return;
if (webhookDockerTag) {
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
}
}
}
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
} else if (sourceType === "github") {
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -365,6 +365,7 @@ export const applicationRouter = createTRPCRouter({
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
createEnvFile: input.createEnvFile,
});
return true;
}),

View File

@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
if (input.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
message:
"You cannot create a environment with the name 'production'",
});
}
// Allow users to create environments with any name, including "production"
const environment = await createEnvironment(input);
if (ctx.user.role === "member") {
@@ -243,13 +245,7 @@ export const environmentRouter = createTRPCRouter({
try {
const { environmentId, ...updateData } = input;
if (updateData.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
});
}
// Allow users to rename environments to any name, including "production"
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
@@ -259,6 +255,13 @@ export const environmentRouter = createTRPCRouter({
);
}
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot update the default environment",
});
}
if (
currentEnvironment.project.organizationId !==
ctx.session.activeOrganizationId

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import {
canAccessToTraefikFiles,
checkGPUStatus,
cleanStoppedContainers,
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
cleanUpUnusedVolumes,
checkPortInUse,
cleanupAll,
cleanupBuilders,
cleanupContainers,
cleanupImages,
cleanupSystem,
cleanupVolumes,
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
let newPorts = ports;
// If receive true, add 8080 to ports
if (input.enableDashboard) {
// Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) {
const conflictingContainer = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({
targetPort: 8080,
publishedPort: 8080,
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
cleanUnusedImages: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanupImages(input?.serverId);
return true;
}),
cleanUnusedVolumes: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedVolumes(input?.serverId);
await cleanupVolumes(input?.serverId);
return true;
}),
cleanStoppedContainers: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanStoppedContainers(input?.serverId);
await cleanupContainers(input?.serverId);
return true;
}),
cleanDockerBuilder: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
await cleanupBuilders(input?.serverId);
}),
cleanDockerPrune: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanupSystem(input?.serverId);
await cleanupBuilders(input?.serverId);
return true;
}),
cleanAll: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanStoppedContainers(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
await cleanupAll(input?.serverId);
return true;
}),
@@ -201,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: input.sshPrivateKey,
});
@@ -213,11 +223,9 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
const user = await updateUser(ctx.user.id, {
const user = await updateUser(ctx.user.ownerId, {
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
}),
letsEncryptEmail: input.letsEncryptEmail,
certificateType: input.certificateType,
https: input.https,
});
@@ -240,7 +248,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: null,
});
return true;
@@ -281,9 +289,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages(server.serverId);
await cleanUpDockerBuilder(server.serverId);
await cleanUpSystemPrune(server.serverId);
await cleanupAll(server.serverId);
await sendDockerCleanupNotifications(server.organizationId);
});
}
@@ -300,7 +308,7 @@ export const settingsRouter = createTRPCRouter({
}
}
} else if (!IS_CLOUD) {
const userUpdated = await updateUser(ctx.user.id, {
const userUpdated = await updateUser(ctx.user.ownerId, {
enableDockerCleanup: input.enableDockerCleanup,
});
@@ -309,9 +317,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await cleanupAll();
await sendDockerCleanupNotifications(
ctx.session.activeOrganizationId,
);
@@ -810,6 +818,19 @@ export const settingsRouter = createTRPCRouter({
"dokploy-traefik",
input?.serverId,
);
for (const port of input.additionalPorts) {
const portCheck = await checkPortInUse(
port.publishedPort,
input.serverId,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
const preparedEnv = prepareEnvironmentVariables(env);
await writeTraefikSetup({

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ import {
createDefaultServerTraefikConfig,
createDefaultTraefikConfig,
IS_CLOUD,
initCancelDeployments,
initCronJobs,
initializeNetwork,
initSchedules,
initVolumeBackupsCronJobs,
initCancelDeployments,
sendDokployRestartNotifications,
setupDirectories,
} from "@dokploy/server";
@@ -26,6 +26,16 @@ config({ path: ".env" });
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
const HOST = process.env.HOST || "0.0.0.0";
const dev = process.env.NODE_ENV !== "production";
// Initialize critical directories and Traefik config BEFORE Next.js starts
// This prevents race conditions with the install script
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
setupDirectories();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
console.log("✅ Critical initialization complete");
}
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
const handle = app.getRequestHandler();
void app.prepare().then(async () => {
@@ -45,11 +55,8 @@ void app.prepare().then(async () => {
}
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
setupDirectories();
createDefaultMiddlewares();
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await migration();
await initCronJobs();
await initSchedules();

View File

@@ -19,6 +19,7 @@ type QueueJob =
type: "schedule";
cronSchedule: string;
scheduleId: string;
timezone?: string | null;
}
| {
type: "volume-backup";

View File

@@ -71,7 +71,9 @@ export const setupDockerContainerLogsWebSocketServer = (
const command = search
? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"`
: baseCommand;
client.exec(command, (err, stream) => {
// Use pty: true to ensure the remote process receives SIGHUP when SSH connection closes
// This is crucial for terminating docker logs processes when the connection is closed
client.exec(command, { pty: true }, (err, stream) => {
if (err) {
console.error("Execution error:", err);
ws.close();

View File

@@ -58,7 +58,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
`docker exec -it -w / ${containerId} ${activeWay}`,
{ pty: true },
(err, stream) => {
if (err) throw err;
if (err) {
console.error("SSH exec error:", err);
ws.close();
conn.end();
return;
}
stream
.on("close", (code: number, _signal: string) => {
@@ -93,10 +98,20 @@ export const setupDockerContainerTerminalWebSocketServer = (
ws.on("close", () => {
stream.end();
// Ensure SSH connection is closed when WebSocket closes
conn.end();
});
},
);
})
.on("error", (err) => {
console.error("SSH connection error:", err);
if (ws.readyState === ws.OPEN) {
ws.send(`SSH error: ${err.message}`);
ws.close();
}
conn.end();
})
.connect({
host: server.ipAddress,
port: server.port,

View File

@@ -31,8 +31,11 @@ export const setupDeploymentLogsWebSocketServer = (
const serverId = url.searchParams.get("serverId");
const { user, session } = await validateRequest(req);
// Generate unique connection ID for tracking
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
if (!logPath) {
console.log("logPath no provided");
console.log(`[${connectionId}] logPath no provided`);
ws.close(4000, "logPath no provided");
return;
}
@@ -42,40 +45,55 @@ export const setupDeploymentLogsWebSocketServer = (
return;
}
let tailProcess: ReturnType<typeof spawn> | null = null;
let sshClient: Client | null = null;
try {
if (serverId) {
const server = await findServerById(serverId);
if (!server.sshKeyId) return;
const client = new Client();
client
if (!server.sshKeyId) {
ws.close();
return;
}
sshClient = new Client();
sshClient
.on("ready", () => {
const command = `
tail -n +1 -f ${logPath};
`;
client.exec(command, (err, stream) => {
sshClient!.exec(command, (err, stream) => {
if (err) {
console.error("Execution error:", err);
sshClient!.end();
ws.close();
return;
}
stream
.on("close", () => {
client.end();
sshClient!.end();
ws.close();
})
.on("data", (data: string) => {
ws.send(data.toString());
if (ws.readyState === ws.OPEN) {
ws.send(data.toString());
}
})
.stderr.on("data", (data) => {
ws.send(data.toString());
if (ws.readyState === ws.OPEN) {
ws.send(data.toString());
}
});
});
})
.on("error", (err) => {
console.error("SSH connection error:", err);
ws.send(`SSH error: ${err.message}`);
ws.close(); // Cierra el WebSocket si hay un error con SSH
if (ws.readyState === ws.OPEN) {
ws.send(`SSH error: ${err.message}`);
ws.close();
}
if (sshClient) {
sshClient.end();
}
})
.connect({
host: server.ipAddress,
@@ -85,26 +103,75 @@ export const setupDeploymentLogsWebSocketServer = (
});
ws.on("close", () => {
client.end();
if (sshClient) {
sshClient.end();
}
});
} else {
const tail = spawn("tail", ["-n", "+1", "-f", logPath]);
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
tail.stdout.on("data", (data) => {
ws.send(data.toString());
});
const stdout = tailProcess.stdout;
const stderr = tailProcess.stderr;
tail.stderr.on("data", (data) => {
ws.send(new Error(`tail error: ${data.toString()}`).message);
});
tail.on("close", () => {
if (stdout) {
stdout.on("data", (data) => {
if (ws.readyState === ws.OPEN) {
ws.send(data.toString());
}
});
}
if (stderr) {
stderr.on("data", (data) => {
if (ws.readyState === ws.OPEN) {
ws.send(new Error(`tail error: ${data.toString()}`).message);
}
});
}
tailProcess.on("close", () => {
ws.close();
});
tailProcess.on("error", () => {
if (ws.readyState === ws.OPEN) {
ws.close();
}
});
ws.on("close", () => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
// Force kill after a timeout if it doesn't terminate
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
} else {
}
}, 1000);
} else {
}
});
}
} catch (error) {
// Clean up resources on error
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
}
}, 1000);
}
if (sshClient) {
sshClient.end();
}
if (ws.readyState === ws.OPEN) {
// @ts-ignore
const errorMessage = error?.message as unknown as string;
ws.send(errorMessage || "An error occurred");
ws.close();
}
} catch {
// @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);
}
});
};

View File

@@ -247,3 +247,18 @@
.cm-lineWrapping {
@apply font-mono;
}
/* HubSpot Widget - Force light color-scheme to prevent white background */
#hubspot-messages-iframe-container,
#hubspot-messages-iframe-container * {
background-color: transparent !important;
color-scheme: light !important;
}
#hubspot-messages-iframe-container .hs-shadow-container {
display: none !important;
}
#hubspot-conversations-iframe {
color-scheme: light !important;
}

View File

@@ -8,21 +8,22 @@
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
@@ -30,7 +31,6 @@
"@dokploy/server/*": ["../../packages/server/src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",

View File

@@ -40,6 +40,7 @@ export const scheduleJob = (job: QueueJob) => {
jobQueue.add(job.scheduleId, job, {
repeat: {
pattern: job.cronSchedule,
tz: job.timezone || "UTC",
},
});
} else if (job.type === "volume-backup") {

View File

@@ -15,6 +15,7 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
cronSchedule: z.string(),
type: z.literal("schedule"),
scheduleId: z.string(),
timezone: z.string().optional(),
}),
z.object({
cronSchedule: z.string(),

View File

@@ -1,7 +1,5 @@
import {
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
cleanupAll,
findBackupById,
findScheduleById,
findServerById,
@@ -91,9 +89,7 @@ export const runJobs = async (job: QueueJob) => {
logger.info("Server is inactive");
return;
}
await cleanUpUnusedImages(serverId);
await cleanUpDockerBuilder(serverId);
await cleanUpSystemPrune(serverId);
await cleanupAll(serverId);
} else if (job.type === "schedule") {
const { scheduleId } = job;
const schedule = await findScheduleById(scheduleId);

View File

@@ -25,7 +25,8 @@
"switch:prod": "node scripts/switchToDist.js",
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"dbml:generate": "npx tsx src/db/schema/dbml.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",

1200
packages/server/schema.dbml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -181,12 +181,19 @@ export const applications = pgTable("application", {
herokuVersion: text("herokuVersion").default("24"),
publishDirectory: text("publishDirectory"),
isStaticSpa: boolean("isStaticSpa"),
createEnvFile: boolean("createEnvFile").notNull().default(true),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
registryId: text("registryId").references(() => registry.registryId, {
onDelete: "set null",
}),
rollbackRegistryId: text("rollbackRegistryId").references(
() => registry.registryId,
{
onDelete: "set null",
},
),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
@@ -270,6 +277,11 @@ export const applicationsRelations = relations(
relationName: "applicationBuildRegistry",
}),
previewDeployments: many(previewDeployments),
rollbackRegistry: one(registry, {
fields: [applications.rollbackRegistryId],
references: [registry.registryId],
relationName: "applicationRollbackRegistry",
}),
}),
);
@@ -321,6 +333,7 @@ const createSchema = createInsertSchema(applications, {
herokuVersion: z.string().optional(),
publishDirectory: z.string().optional(),
isStaticSpa: z.boolean().optional(),
createEnvFile: z.boolean().optional(),
owner: z.string(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
@@ -490,6 +503,7 @@ export const apiSaveEnvironmentVariables = createSchema
env: true,
buildArgs: true,
buildSecrets: true,
createEnvFile: true,
})
.required();

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -26,6 +26,7 @@ export const environments = pgTable("environment", {
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
isDefault: boolean("isDefault").notNull().default(false),
});
export const environmentRelations = relations(
@@ -69,9 +70,14 @@ export const apiRemoveEnvironment = createSchema
})
.required();
export const apiUpdateEnvironment = createSchema.partial().extend({
environmentId: z.string().min(1),
});
export const apiUpdateEnvironment = createSchema
.partial()
.extend({
environmentId: z.string().min(1),
})
.omit({
isDefault: true,
});
export const apiDuplicateEnvironment = createSchema
.pick({

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -12,6 +19,7 @@ export const notificationType = pgEnum("notificationType", [
"email",
"gotify",
"ntfy",
"custom",
"lark",
]);
@@ -24,6 +32,7 @@ export const notifications = pgTable("notification", {
appDeploy: boolean("appDeploy").notNull().default(false),
appBuildError: boolean("appBuildError").notNull().default(false),
databaseBackup: boolean("databaseBackup").notNull().default(false),
volumeBackup: boolean("volumeBackup").notNull().default(false),
dokployRestart: boolean("dokployRestart").notNull().default(false),
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
serverThreshold: boolean("serverThreshold").notNull().default(false),
@@ -49,6 +58,9 @@ export const notifications = pgTable("notification", {
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
customId: text("customId").references(() => custom.customId, {
onDelete: "cascade",
}),
larkId: text("larkId").references(() => lark.larkId, {
onDelete: "cascade",
}),
@@ -120,6 +132,15 @@ export const ntfy = pgTable("ntfy", {
priority: integer("priority").notNull().default(3),
});
export const custom = pgTable("custom", {
customId: text("customId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
endpoint: text("endpoint").notNull(),
headers: jsonb("headers").$type<Record<string, string>>(),
});
export const lark = pgTable("lark", {
larkId: text("larkId")
.notNull()
@@ -153,6 +174,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
custom: one(custom, {
fields: [notifications.customId],
references: [custom.customId],
}),
lark: one(lark, {
fields: [notifications.larkId],
references: [lark.larkId],
@@ -169,6 +194,7 @@ export const apiCreateSlack = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -196,6 +222,7 @@ export const apiCreateTelegram = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -225,6 +252,7 @@ export const apiCreateDiscord = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -255,6 +283,7 @@ export const apiCreateEmail = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -290,6 +319,7 @@ export const apiCreateGotify = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -323,6 +353,7 @@ export const apiCreateNtfy = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
@@ -355,6 +386,32 @@ export const apiFindOneNotification = notificationsSchema
})
.required();
export const apiCreateCustom = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
endpoint: z.string().min(1),
headers: z.record(z.string()).optional(),
});
export const apiUpdateCustom = apiCreateCustom.partial().extend({
notificationId: z.string().min(1),
customId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestCustomConnection = z.object({
endpoint: z.string().min(1),
headers: z.record(z.string()).optional(),
});
export const apiCreateLark = notificationsSchema
.pick({
appBuildError: true,
@@ -397,5 +454,7 @@ export const apiSendTest = notificationsSchema
appToken: z.string(),
accessToken: z.string().optional(),
priority: z.number(),
endpoint: z.string(),
headers: z.string(),
})
.partial();

View File

@@ -39,6 +39,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
buildApplications: many(applications, {
relationName: "applicationBuildRegistry",
}),
rollbackApplications: many(applications, {
relationName: "applicationRollbackRegistry",
}),
}));
const createSchema = createInsertSchema(registry, {

View File

@@ -49,6 +49,7 @@ export const schedules = pgTable("schedule", {
onDelete: "cascade",
}),
enabled: boolean("enabled").notNull().default(true),
timezone: text("timezone"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),

View File

@@ -5,17 +5,24 @@ enum applicationStatus {
error
}
enum backupType {
database
compose
}
enum buildType {
dockerfile
heroku_buildpacks
paketo_buildpacks
nixpacks
static
railpack
}
enum certificateType {
letsencrypt
none
custom
}
enum composeType {
@@ -28,6 +35,7 @@ enum databaseType {
mariadb
mysql
mongo
"web-server"
}
enum deploymentStatus {
@@ -61,6 +69,8 @@ enum notificationType {
discord
email
gotify
ntfy
custom
}
enum protocolType {
@@ -68,14 +78,21 @@ enum protocolType {
udp
}
enum publishModeType {
ingress
host
}
enum RegistryType {
selfHosted
cloud
}
enum Roles {
admin
user
enum scheduleType {
application
compose
server
"dokploy-server"
}
enum serverStatus {
@@ -93,6 +110,11 @@ enum serviceType {
compose
}
enum shellType {
bash
sh
}
enum sourceType {
docker
git
@@ -112,6 +134,11 @@ enum sourceTypeCompose {
raw
}
enum triggerType {
push
tag
}
table account {
id text [pk, not null]
account_id text [not null]
@@ -133,7 +160,39 @@ table account {
confirmationExpiresAt text
}
table admin {
table ai {
aiId text [pk, not null]
name text [not null]
apiUrl text [not null]
apiKey text [not null]
model text [not null]
isEnabled boolean [not null, default: true]
organizationId text [not null]
createdAt text [not null]
}
table apikey {
id text [pk, not null]
name text
start text
prefix text
key text [not null]
user_id text [not null]
refill_interval integer
refill_amount integer
last_refill_at timestamp
enabled boolean
rate_limit_enabled boolean
rate_limit_time_window integer
rate_limit_max integer
request_count integer
remaining integer
last_request timestamp
expires_at timestamp
created_at timestamp [not null]
updated_at timestamp [not null]
permissions text
metadata text
}
table application {
@@ -143,14 +202,19 @@ table application {
description text
env text
previewEnv text
watchPaths text[]
previewBuildArgs text
previewLabels text[]
previewWildcard text
previewPort integer [default: 3000]
previewHttps boolean [not null, default: false]
previewPath text [default: '/']
certificateType certificateType [not null, default: 'none']
previewCustomCertResolver text
previewLimit integer [default: 3]
isPreviewDeploymentsActive boolean [default: false]
previewRequireCollaboratorPermissions boolean [default: true]
rollbackActive boolean [default: false]
buildArgs text
memoryReservation text
memoryLimit text
@@ -167,6 +231,7 @@ table application {
owner text
branch text
buildPath text [default: '/']
triggerType triggerType [default: 'push']
autoDeploy boolean
gitlabProjectId integer
gitlabRepository text
@@ -174,6 +239,10 @@ table application {
gitlabBranch text
gitlabBuildPath text [default: '/']
gitlabPathNamespace text
giteaRepository text
giteaOwner text
giteaBranch text
giteaBuildPath text [default: '/']
bitbucketRepository text
bitbucketOwner text
bitbucketBranch text
@@ -186,6 +255,7 @@ table application {
customGitBranch text
customGitBuildPath text
customGitSSHKeyId text
enableSubmodules boolean [not null, default: false]
dockerfile text
dockerContextPath text
dockerBuildStage text
@@ -201,52 +271,47 @@ table application {
replicas integer [not null, default: 1]
applicationStatus applicationStatus [not null, default: 'idle']
buildType buildType [not null, default: 'nixpacks']
railpackVersion text [default: '0.2.2']
herokuVersion text [default: '24']
publishDirectory text
isStaticSpa boolean
createdAt text [not null]
registryId text
projectId text [not null]
environmentId text [not null]
githubId text
gitlabId text
bitbucketId text
giteaId text
bitbucketId text
serverId text
}
table auth {
id text [pk, not null]
email text [not null, unique]
password text [not null]
rol Roles [not null]
image text
secret text
token text
is2FAEnabled boolean [not null, default: false]
createdAt text [not null]
resetPasswordToken text
resetPasswordExpiresAt text
confirmationToken text
confirmationExpiresAt text
}
table backup {
backupId text [pk, not null]
appName text [not null, unique]
schedule text [not null]
enabled boolean
database text [not null]
prefix text [not null]
serviceName text
destinationId text [not null]
keepLatestCount integer
backupType backupType [not null, default: 'database']
databaseType databaseType [not null]
composeId text
postgresId text
mariadbId text
mysqlId text
mongoId text
userId text
metadata jsonb
}
table bitbucket {
bitbucketId text [pk, not null]
bitbucketUsername text
bitbucketEmail text
appPassword text
apiToken text
bitbucketWorkspaceName text
gitProviderId text [not null]
}
@@ -258,7 +323,7 @@ table certificate {
privateKey text [not null]
certificatePath text [not null, unique]
autoRenew boolean
userId text
organizationId text [not null]
serverId text
}
@@ -291,13 +356,17 @@ table compose {
customGitBranch text
customGitSSHKeyId text
command text [not null, default: '']
enableSubmodules boolean [not null, default: false]
composePath text [not null, default: './docker-compose.yml']
suffix text [not null, default: '']
randomize boolean [not null, default: false]
isolatedDeployment boolean [not null, default: false]
isolatedDeploymentsVolume boolean [not null, default: false]
triggerType triggerType [default: 'push']
composeStatus applicationStatus [not null, default: 'idle']
projectId text [not null]
environmentId text [not null]
createdAt text [not null]
watchPaths text[]
githubId text
gitlabId text
bitbucketId text
@@ -305,19 +374,32 @@ table compose {
serverId text
}
table custom {
customId text [pk, not null]
endpoint text [not null]
headers text
}
table deployment {
deploymentId text [pk, not null]
title text [not null]
description text
status deploymentStatus [default: 'running']
logPath text [not null]
pid text
applicationId text
composeId text
serverId text
isPreviewDeployment boolean [default: false]
previewDeploymentId text
createdAt text [not null]
startedAt text
finishedAt text
errorMessage text
scheduleId text
backupId text
rollbackId text
volumeBackupId text
}
table destination {
@@ -329,7 +411,8 @@ table destination {
bucket text [not null]
region text [not null]
endpoint text [not null]
userId text [not null]
organizationId text [not null]
createdAt timestamp [not null, default: `now()`]
}
table discord {
@@ -349,9 +432,12 @@ table domain {
uniqueConfigKey serial [not null, increment]
createdAt text [not null]
composeId text
customCertResolver text
applicationId text
previewDeploymentId text
certificateType certificateType [not null, default: 'none']
internalPath text [default: '/']
stripPath boolean [not null, default: false]
}
table email {
@@ -364,12 +450,36 @@ table email {
toAddress text[] [not null]
}
table environment {
environmentId text [pk, not null]
name text [not null]
description text
createdAt text [not null]
env text [not null, default: '']
projectId text [not null]
}
table git_provider {
gitProviderId text [pk, not null]
name text [not null]
providerType gitProviderType [not null, default: 'github']
createdAt text [not null]
userId text
organizationId text [not null]
userId text [not null]
}
table gitea {
giteaId text [pk, not null]
giteaUrl text [not null, default: 'https://gitea.com']
redirect_uri text
client_id text
client_secret text
gitProviderId text [not null]
access_token text
refresh_token text
expires_at integer
scopes text [default: 'repo,repo:status,read:user,read:org']
last_authenticated_at integer
}
table github {
@@ -397,20 +507,6 @@ table gitlab {
gitProviderId text [not null]
}
table gitea {
giteaId text [pk, not null]
giteaUrl text [not null, default: 'https://gitea.com']
redirect_uri text
client_id text [not null]
client_secret text [not null]
access_token text
refresh_token text
expires_at integer
gitProviderId text [not null]
scopes text [default: 'repo,repo:status,read:user,read:org']
last_authenticated_at integer
}
table gotify {
gotifyId text [pk, not null]
serverUrl text [not null]
@@ -427,6 +523,7 @@ table invitation {
status text [not null]
expires_at timestamp [not null]
inviter_id text [not null]
team_id text
}
table mariadb {
@@ -447,8 +544,17 @@ table mariadb {
cpuLimit text
externalPort integer
applicationStatus applicationStatus [not null, default: 'idle']
healthCheckSwarm json
restartPolicySwarm json
placementSwarm json
updateConfigSwarm json
rollbackConfigSwarm json
modeSwarm json
labelsSwarm json
networkSwarm json
replicas integer [not null, default: 1]
createdAt text [not null]
projectId text [not null]
environmentId text [not null]
serverId text
}
@@ -458,6 +564,19 @@ table member {
user_id text [not null]
role text [not null]
created_at timestamp [not null]
team_id text
canCreateProjects boolean [not null, default: false]
canAccessToSSHKeys boolean [not null, default: false]
canCreateServices boolean [not null, default: false]
canDeleteProjects boolean [not null, default: false]
canDeleteServices boolean [not null, default: false]
canAccessToDocker boolean [not null, default: false]
canAccessToAPI boolean [not null, default: false]
canAccessToGitProviders boolean [not null, default: false]
canAccessToTraefikFiles boolean [not null, default: false]
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
accessedEnvironments text[] [not null, default: `ARRAY[]::text[]`]
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
}
table mongo {
@@ -476,8 +595,17 @@ table mongo {
cpuLimit text
externalPort integer
applicationStatus applicationStatus [not null, default: 'idle']
healthCheckSwarm json
restartPolicySwarm json
placementSwarm json
updateConfigSwarm json
rollbackConfigSwarm json
modeSwarm json
labelsSwarm json
networkSwarm json
replicas integer [not null, default: 1]
createdAt text [not null]
projectId text [not null]
environmentId text [not null]
serverId text
replicaSets boolean [default: false]
}
@@ -518,8 +646,17 @@ table mysql {
cpuLimit text
externalPort integer
applicationStatus applicationStatus [not null, default: 'idle']
healthCheckSwarm json
restartPolicySwarm json
placementSwarm json
updateConfigSwarm json
rollbackConfigSwarm json
modeSwarm json
labelsSwarm json
networkSwarm json
replicas integer [not null, default: 1]
createdAt text [not null]
projectId text [not null]
environmentId text [not null]
serverId text
}
@@ -539,7 +676,17 @@ table notification {
discordId text
emailId text
gotifyId text
userId text
ntfyId text
customId text
organizationId text [not null]
}
table ntfy {
ntfyId text [pk, not null]
serverUrl text [not null]
topic text [not null]
accessToken text [not null]
priority integer [not null, default: 3]
}
table organization {
@@ -555,6 +702,7 @@ table organization {
table port {
portId text [pk, not null]
publishedPort integer [not null]
publishMode publishModeType [not null, default: 'host']
targetPort integer [not null]
protocol protocolType [not null]
applicationId text [not null]
@@ -577,8 +725,17 @@ table postgres {
cpuReservation text
cpuLimit text
applicationStatus applicationStatus [not null, default: 'idle']
healthCheckSwarm json
restartPolicySwarm json
placementSwarm json
updateConfigSwarm json
rollbackConfigSwarm json
modeSwarm json
labelsSwarm json
networkSwarm json
replicas integer [not null, default: 1]
createdAt text [not null]
projectId text [not null]
environmentId text [not null]
serverId text
}
@@ -603,7 +760,7 @@ table project {
name text [not null]
description text
createdAt text [not null]
userId text [not null]
organizationId text [not null]
env text [not null, default: '']
}
@@ -633,7 +790,16 @@ table redis {
externalPort integer
createdAt text [not null]
applicationStatus applicationStatus [not null, default: 'idle']
projectId text [not null]
healthCheckSwarm json
restartPolicySwarm json
placementSwarm json
updateConfigSwarm json
rollbackConfigSwarm json
modeSwarm json
labelsSwarm json
networkSwarm json
replicas integer [not null, default: 1]
environmentId text [not null]
serverId text
}
@@ -646,7 +812,34 @@ table registry {
registryUrl text [not null, default: '']
createdAt text [not null]
selfHosted RegistryType [not null, default: 'cloud']
userId text [not null]
organizationId text [not null]
}
table rollback {
rollbackId text [pk, not null]
deploymentId text [not null]
version serial [not null, increment]
image text
createdAt text [not null]
fullContext jsonb
}
table schedule {
scheduleId text [pk, not null]
name text [not null]
cronExpression text [not null]
appName text [not null]
serviceName text
shellType shellType [not null, default: 'bash']
scheduleType scheduleType [not null, default: 'application']
command text [not null]
script text
applicationId text
composeId text
serverId text
userId text
enabled boolean [not null, default: true]
createdAt text [not null]
}
table security {
@@ -671,14 +864,14 @@ table server {
appName text [not null]
enableDockerCleanup boolean [not null, default: false]
createdAt text [not null]
userId text [not null]
organizationId text [not null]
serverStatus serverStatus [not null, default: 'active']
command text [not null, default: '']
sshKeyId text
metricsConfig jsonb [not null, default: `{"server":{"type":"Remote","refreshRate":60,"port":4500,"token":"","urlCallback":"","cronJob":"","retentionDays":2,"thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
}
table session {
table session_temp {
id text [pk, not null]
expires_at timestamp [not null]
token text [not null, unique]
@@ -705,49 +898,49 @@ table "ssh-key" {
description text
createdAt text [not null]
lastUsedAt text
userId text
organizationId text [not null]
}
table telegram {
telegramId text [pk, not null]
botToken text [not null]
chatId text [not null]
messageThreadId text
}
table user {
table two_factor {
id text [pk, not null]
secret text [not null]
backup_codes text [not null]
user_id text [not null]
}
table user_temp {
id text [pk, not null]
name text [not null, default: '']
token text [not null]
isRegistered boolean [not null, default: false]
expirationDate text [not null]
createdAt text [not null]
canCreateProjects boolean [not null, default: false]
canAccessToSSHKeys boolean [not null, default: false]
canCreateServices boolean [not null, default: false]
canDeleteProjects boolean [not null, default: false]
canDeleteServices boolean [not null, default: false]
canAccessToDocker boolean [not null, default: false]
canAccessToAPI boolean [not null, default: false]
canAccessToGitProviders boolean [not null, default: false]
canAccessToTraefikFiles boolean [not null, default: false]
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
created_at timestamp [default: `now()`]
two_factor_enabled boolean
email text [not null, unique]
email_verified boolean [not null]
image text
role text
banned boolean
ban_reason text
ban_expires timestamp
updated_at timestamp [not null]
serverIp text
certificateType certificateType [not null, default: 'none']
https boolean [not null, default: false]
host text
letsEncryptEmail text
sshPrivateKey text
enableDockerCleanup boolean [not null, default: false]
enableLogRotation boolean [not null, default: false]
logCleanupCron text [default: '0 0 * * *']
role text [not null, default: 'user']
enablePaidFeatures boolean [not null, default: false]
allowImpersonation boolean [not null, default: false]
metricsConfig jsonb [not null, default: `{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
cleanupCacheApplications boolean [not null, default: false]
cleanupCacheOnPreviews boolean [not null, default: false]
@@ -766,6 +959,29 @@ table verification {
updated_at timestamp
}
table volume_backup {
volumeBackupId text [pk, not null]
name text [not null]
volumeName text [not null]
prefix text [not null]
serviceType serviceType [not null, default: 'application']
appName text [not null]
serviceName text
turnOff boolean [not null, default: false]
cronExpression text [not null]
keepLatestCount integer
enabled boolean
applicationId text
postgresId text
mariadbId text
mongoId text
mysqlId text
redisId text
composeId text
createdAt text [not null]
destinationId text [not null]
}
ref: mount.applicationId > application.applicationId
ref: mount.postgresId > postgres.postgresId
@@ -780,7 +996,13 @@ ref: mount.redisId > redis.redisId
ref: mount.composeId > compose.composeId
ref: application.projectId > project.projectId
ref: user_temp.id - account.user_id
ref: ai.organizationId - organization.id
ref: apikey.user_id > user_temp.id
ref: application.environmentId > environment.environmentId
ref: application.customGitSSHKeyId > "ssh-key".sshKeyId
@@ -790,6 +1012,8 @@ ref: application.githubId - github.githubId
ref: application.gitlabId - gitlab.gitlabId
ref: application.giteaId - gitea.giteaId
ref: application.bitbucketId - bitbucket.bitbucketId
ref: application.serverId > server.serverId
@@ -804,13 +1028,17 @@ ref: backup.mysqlId > mysql.mysqlId
ref: backup.mongoId > mongo.mongoId
ref: backup.userId > user_temp.id
ref: backup.composeId > compose.composeId
ref: git_provider.gitProviderId - bitbucket.gitProviderId
ref: certificate.serverId > server.serverId
ref: certificate.userId - user.id
ref: certificate.organizationId - organization.id
ref: compose.projectId > project.projectId
ref: compose.environmentId > environment.environmentId
ref: compose.customGitSSHKeyId > "ssh-key".sshKeyId
@@ -820,6 +1048,8 @@ ref: compose.gitlabId - gitlab.gitlabId
ref: compose.bitbucketId - bitbucket.bitbucketId
ref: compose.giteaId - gitea.giteaId
ref: compose.serverId > server.serverId
ref: deployment.applicationId > application.applicationId
@@ -830,7 +1060,15 @@ ref: deployment.serverId > server.serverId
ref: deployment.previewDeploymentId > preview_deployments.previewDeploymentId
ref: destination.userId - user.id
ref: deployment.scheduleId > schedule.scheduleId
ref: deployment.backupId > backup.backupId
ref: rollback.deploymentId - deployment.deploymentId
ref: deployment.volumeBackupId > volume_backup.volumeBackupId
ref: destination.organizationId - organization.id
ref: domain.applicationId > application.applicationId
@@ -838,23 +1076,33 @@ ref: domain.composeId > compose.composeId
ref: preview_deployments.domainId - domain.domainId
ref: environment.projectId > project.projectId
ref: github.gitProviderId - git_provider.gitProviderId
ref: gitlab.gitProviderId - git_provider.gitProviderId
ref: gitea.gitProviderId - git_provider.gitProviderId
ref: git_provider.userId - user.id
ref: git_provider.organizationId - organization.id
ref: mariadb.projectId > project.projectId
ref: git_provider.userId - user_temp.id
ref: invitation.organization_id - organization.id
ref: mariadb.environmentId > environment.environmentId
ref: mariadb.serverId > server.serverId
ref: mongo.projectId > project.projectId
ref: member.organization_id > organization.id
ref: member.user_id - user_temp.id
ref: mongo.environmentId > environment.environmentId
ref: mongo.serverId > server.serverId
ref: mysql.projectId > project.projectId
ref: mysql.environmentId > environment.environmentId
ref: mysql.serverId > server.serverId
@@ -868,30 +1116,58 @@ ref: notification.emailId - email.emailId
ref: notification.gotifyId - gotify.gotifyId
ref: notification.userId - user.id
ref: notification.ntfyId - ntfy.ntfyId
ref: notification.customId - custom.customId
ref: notification.organizationId - organization.id
ref: organization.owner_id > user_temp.id
ref: port.applicationId > application.applicationId
ref: postgres.projectId > project.projectId
ref: postgres.environmentId > environment.environmentId
ref: postgres.serverId > server.serverId
ref: preview_deployments.applicationId > application.applicationId
ref: project.userId - user.id
ref: project.organizationId > organization.id
ref: redirect.applicationId > application.applicationId
ref: redis.projectId > project.projectId
ref: redis.environmentId > environment.environmentId
ref: redis.serverId > server.serverId
ref: registry.userId - user.id
ref: schedule.applicationId - application.applicationId
ref: schedule.composeId > compose.composeId
ref: schedule.serverId > server.serverId
ref: schedule.userId > user_temp.id
ref: security.applicationId > application.applicationId
ref: server.userId - user.id
ref: server.sshKeyId > "ssh-key".sshKeyId
ref: "ssh-key".userId - user.id
ref: server.organizationId > organization.id
ref: "ssh-key".organizationId - organization.id
ref: volume_backup.applicationId - application.applicationId
ref: volume_backup.postgresId - postgres.postgresId
ref: volume_backup.mariadbId - mariadb.mariadbId
ref: volume_backup.mongoId - mongo.mongoId
ref: volume_backup.mysqlId - mysql.mysqlId
ref: volume_backup.redisId - redis.redisId
ref: volume_backup.composeId - compose.composeId
ref: volume_backup.destinationId - destination.destinationId

View File

@@ -31,7 +31,8 @@ export const user = pgTable("user", {
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull().default(""),
firstName: text("firstName").notNull().default(""),
lastName: text("lastName").notNull().default(""),
isRegistered: boolean("isRegistered").notNull().default(false),
expirationDate: text("expirationDate")
.notNull()
@@ -56,7 +57,7 @@ export const user = pgTable("user", {
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
role: text("role").notNull().default("user"),
// Metrics
@@ -332,6 +333,7 @@ export const apiUpdateUser = createSchema.partial().extend({
password: z.string().optional(),
currentPassword: z.string().optional(),
name: z.string().optional(),
lastName: z.string().optional(),
metricsConfig: z
.object({
server: z.object({

View File

@@ -0,0 +1,123 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
projectName: string;
applicationName: string;
volumeName: string;
serviceType:
| "application"
| "postgres"
| "mysql"
| "mongodb"
| "mariadb"
| "redis"
| "compose";
type: "error" | "success";
errorMessage?: string;
backupSize?: string;
date: string;
};
export const VolumeBackupEmail = ({
projectName = "dokploy",
applicationName = "frontend",
volumeName = "app-data",
serviceType = "application",
type = "success",
errorMessage,
backupSize,
date = "2023-05-01T00:00:00.000Z",
}: TemplateProps) => {
const previewText = `Volume backup for ${applicationName} was ${type === "success" ? "successful ✅" : "failed ❌"}`;
return (
<Html>
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Head />
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Volume backup for <strong>{applicationName}</strong>
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
Your volume backup for <strong>{applicationName}</strong> was{" "}
{type === "success"
? "successful ✅"
: "failed. Please check the error message below. ❌"}
.
</Text>
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Details: </Text>
<Text className="!leading-3">
Project Name: <strong>{projectName}</strong>
</Text>
<Text className="!leading-3">
Application Name: <strong>{applicationName}</strong>
</Text>
<Text className="!leading-3">
Volume Name: <strong>{volumeName}</strong>
</Text>
<Text className="!leading-3">
Service Type: <strong>{serviceType}</strong>
</Text>
{backupSize && (
<Text className="!leading-3">
Backup Size: <strong>{backupSize}</strong>
</Text>
)}
<Text className="!leading-3">
Date: <strong>{date}</strong>
</Text>
</Section>
{type === "error" && errorMessage ? (
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Reason: </Text>
<Text className="text-[12px] leading-[24px]">
{errorMessage || "Error message not provided"}
</Text>
</Section>
) : null}
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default VolumeBackupEmail;

View File

@@ -132,11 +132,15 @@ const { handler, api } = betterAuth({
const hutk = getHubSpotUTK(
context?.request?.headers?.get("cookie") || undefined,
);
// Cast to include additional fields
const userWithFields = user as typeof user & {
lastName?: string;
};
const hubspotSuccess = await submitToHubSpot(
{
email: user.email,
firstName: user.name,
lastName: user.name,
firstName: user.name || "", // name is mapped to firstName column
lastName: userWithFields.lastName || "",
},
hutk,
);
@@ -204,6 +208,9 @@ const { handler, api } = betterAuth({
},
user: {
modelName: "user",
fields: {
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
},
additionalFields: {
role: {
type: "string",
@@ -220,6 +227,12 @@ const { handler, api } = betterAuth({
type: "boolean",
defaultValue: false,
},
lastName: {
type: "string",
required: false,
input: true,
defaultValue: "",
},
},
},
plugins: [
@@ -316,16 +329,11 @@ export const validateRequest = async (request: IncomingMessage) => {
},
});
const {
id,
name,
email,
emailVerified,
image,
createdAt,
updatedAt,
twoFactorEnabled,
} = apiKeyRecord.user;
// When accessing from DB, use actual column names
const userFromDb = apiKeyRecord.user as typeof apiKeyRecord.user & {
firstName: string;
lastName: string;
};
const mockSession = {
session: {
@@ -333,14 +341,14 @@ export const validateRequest = async (request: IncomingMessage) => {
activeOrganizationId: organizationId || "",
},
user: {
id,
name,
email,
emailVerified,
image,
createdAt,
updatedAt,
twoFactorEnabled,
id: userFromDb.id,
name: userFromDb.firstName, // Map firstName back to name for better-auth
email: userFromDb.email,
emailVerified: userFromDb.emailVerified,
image: userFromDb.image,
createdAt: userFromDb.createdAt,
updatedAt: userFromDb.updatedAt,
twoFactorEnabled: userFromDb.twoFactorEnabled,
role: member?.role || "member",
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
},

View File

@@ -46,7 +46,7 @@ export const isAdminPresent = async () => {
return true;
};
export const findAdmin = async () => {
export const findOwner = async () => {
const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
@@ -107,11 +107,11 @@ export const getDokployUrl = async () => {
if (IS_CLOUD) {
return "https://app.dokploy.com";
}
const admin = await findAdmin();
const owner = await findOwner();
if (admin.user.host) {
const protocol = admin.user.https ? "https" : "http";
return `${protocol}://${admin.user.host}`;
if (owner.user.host) {
const protocol = owner.user.https ? "https" : "http";
return `${protocol}://${owner.user.host}`;
}
return `http://${admin.user.serverIp}:${process.env.PORT}`;
return `http://${owner.user.serverIp}:${process.env.PORT}`;
};

View File

@@ -104,14 +104,28 @@ export const suggestVariants = async ({
),
}),
prompt: `
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items).
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-slug",
"name": "Project Name",
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
@@ -120,10 +134,14 @@ export const suggestVariants = async ({
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. The description field should ONLY contain a plain text description of the project, its features, and use cases
3. Do NOT include any code snippets, configuration examples, or installation instructions in the description
4. The shortDescription should be a single-line summary focusing on the main technologies
5. All projects should be installable in docker and have docker compose support
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
@@ -186,6 +204,24 @@ export const suggestVariants = async ({
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
@@ -214,6 +250,8 @@ export const suggestVariants = async ({
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,

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