Compare commits

..

345 Commits

Author SHA1 Message Date
Mauricio Siu
b965dedd7d Merge pull request #3407 from mhbdev/fix-ui-deployments-page
UI responsiveness in Deployments tab
2026-01-12 10:14:32 -06:00
Mauricio Siu
2b779f9fc6 Merge pull request #3444 from Dokploy/feat/add-railpack-selector-version
feat(build): add Railpack version selection with manual input option
2026-01-12 09:49:25 -06:00
Mauricio Siu
15b0ca7ab2 fix(input): add type safety for input reference handling 2026-01-12 09:49:13 -06:00
autofix-ci[bot]
fd6f61fd2a [autofix.ci] apply automated fixes 2026-01-12 15:47:51 +00:00
Mauricio Siu
8f95546535 Merge pull request #3410 from vikyw89/canary
fix: admin permission frontend side, should be able to see what owner can see
2026-01-12 09:32:28 -06:00
Mauricio Siu
8b370d4f7b Merge pull request #3370 from krishna2206/fix/gemini-ai-error
fix(selectAIProvider): add authorization header for Gemini provider
2026-01-12 09:28:21 -06:00
Mauricio Siu
1ed941b17c Merge pull request #3409 from mhbdev/auto-password-generator
Added a built-in password generator to the shared input
2026-01-12 09:21:28 -06:00
Mauricio Siu
18d980c3ff feat: enable password generator for database inputs and disable it for profile settings 2026-01-12 09:19:22 -06:00
Mauricio Siu
5ddcdd843c Merge branch 'canary' into auto-password-generator 2026-01-12 09:15:18 -06:00
Mauricio Siu
fdf88b1ff3 feat(build): add Railpack version selection with manual input option
- Introduced a dropdown for selecting Railpack versions, including a manual entry option for custom versions.
- Implemented state management to toggle between predefined versions and manual input.
- Updated form handling to accommodate the new selection method and provide user guidance.
2026-01-12 09:13:18 -06:00
autofix-ci[bot]
13b64e45ec [autofix.ci] apply automated fixes 2026-01-12 15:06:20 +00:00
Mauricio Siu
4383e46686 Merge pull request #3290 from amirhmoradi/claude/update-dockerfile-deps-WD7Lw
feat: Update build dependencies to their latest versions
2026-01-12 09:05:34 -06:00
Mauricio Siu
60d69d2915 Delete .claude/settings.local.json 2026-01-12 09:03:09 -06:00
autofix-ci[bot]
a2b16d4be8 [autofix.ci] apply automated fixes 2026-01-12 15:02:33 +00:00
Mauricio Siu
831a1815cf Merge pull request #3389 from tanmay-pathak/preview-deploy-rebuild
feat(preview):  add manual rebuild option for previews
2026-01-12 09:01:01 -06:00
Mauricio Siu
6b9bcbc539 feat(schema): extend deployJobSchema to include 'redeploy' type and enhance auth settings for development environment 2026-01-12 08:57:45 -06:00
Mauricio Siu
6ca6ff3530 Merge branch 'canary' into preview-deploy-rebuild 2026-01-12 08:46:19 -06:00
autofix-ci[bot]
7583d5f860 [autofix.ci] apply automated fixes 2026-01-12 14:45:09 +00:00
Mauricio Siu
7921f754fd Merge pull request #3427 from bdkopen/remove-@nerimity/mimiqueue
chore: uninstall `@nerimity/mimiqueue`
2026-01-12 08:44:24 -06:00
Mauricio Siu
0c0944d221 Update package.json 2026-01-11 22:16:50 -06:00
Mauricio Siu
d490111a58 Merge pull request #3441 from Dokploy/3260-dokploy-automatically-updates-itself-but-automated-updates-are-disabled-in-the-settings
chore(dependencies): update semver to version 7.7.3 and add @types/se…
2026-01-11 22:16:09 -06:00
Mauricio Siu
167daccee0 feat(settings): enhance getUpdateData and reloadDockerResource for image digest comparison
- Added logic to getUpdateData to compare current and latest image digests for canary and feature tags, indicating if an update is available.
- Updated reloadDockerResource to ensure the correct image tag is used during dokploy service updates based on the current image tag.
2026-01-11 22:12:39 -06:00
Mauricio Siu
11af6a5eb9 feat(docker): enhance reloadDockerResource to accept version parameter for dokploy updates
- Updated the reloadDockerResource function to include an optional version parameter.
- Modified the command for updating the dokploy service to specify the image version during updates.
2026-01-11 21:58:04 -06:00
Mauricio Siu
85424badcf chore(dependencies): update semver to version 7.7.3 and add @types/semver to package.json files; refactor getUpdateData function to accept current version as a parameter 2026-01-11 21:51:56 -06:00
Mauricio Siu
ccfd7f5189 Merge pull request #3439 from Dokploy/3102-web-server-backup-keep-the-latest-not-working
feat(backup): add functionality to keep the latest N backups after ru…
2026-01-11 20:44:39 -06:00
Mauricio Siu
6d94da1dee feat(backup): add functionality to keep the latest N backups after running a backup 2026-01-11 20:44:16 -06:00
Mauricio Siu
10c0de9d5f Merge pull request #3431 from Dokploy/copilot/fix-invalid-link-view-repository
Fix GitLab "View Repository" link to use full path namespace and custom URL
2026-01-11 20:29:10 -06:00
Mauricio Siu
2b0ae65f71 Merge pull request #3438 from Dokploy/feat/add-invoices-billing
Feat/add invoices billing
2026-01-11 20:25:21 -06:00
autofix-ci[bot]
2acaaede37 [autofix.ci] apply automated fixes 2026-01-12 02:22:33 +00:00
Mauricio Siu
f303962319 fix(database): update container name query to use exact match
- Modified the SQL queries in GetLastNContainerMetrics and GetAllMetricsContainer functions to use an exact match for container names instead of a LIKE clause, improving query accuracy and performance.
2026-01-11 20:21:41 -06:00
Mauricio Siu
edc8efe816 refactor(servers): replace DropdownMenuItem with Button for Setup Server action
- Updated the SetupServer component to use a Button instead of DropdownMenuItem for better accessibility and user experience.
- Enhanced the ShowServers component by adding tooltips for the Setup Server action, providing users with additional context on server configuration.
2026-01-11 19:21:29 -06:00
Mauricio Siu
4e0cb2a9c7 feat(billing): add billing invoices page and update billing components
- Introduced `ShowBillingInvoices` component to manage and display billing invoices.
- Updated `ShowBilling` component to include navigation for invoices and enhanced subscription management.
- Refactored `ShowInvoices` component for improved loading and display logic.
- Created a new invoices page with server-side validation and layout integration.
2026-01-11 18:34:14 -06:00
Mauricio Siu
4001f1d067 feat(billing): implement invoice display and retrieval functionality
- Added `ShowInvoices` component to display user invoices with status and actions.
- Integrated Stripe API to fetch invoices for the authenticated user.
- Updated `ShowBilling` component to conditionally render invoices if the user has a Stripe customer ID.
2026-01-11 18:27:19 -06:00
Mauricio Siu
d894b2a3bf feat(stripe): add customer_email to payment metadata 2026-01-11 18:17:19 -06:00
copilot-swe-agent[bot]
14d359dd14 Fix GitLab View Repository links to use correct URL and namespace
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-01-10 17:45:17 +00:00
copilot-swe-agent[bot]
1e11f603de Initial plan 2026-01-10 17:41:46 +00:00
bdkopen
d12f029e2b chore: uninstall @nerimity/mimiqueue 2026-01-10 00:11:26 -05:00
Amir Moradi
0c62bc0f29 fix: create migrations and update to latest railpack 2026-01-08 12:29:42 +01:00
Amir Moradi
b19d3e94eb Merge branch 'canary' of github.com:amirhmoradi/dokploy into claude/update-dockerfile-deps-WD7Lw 2026-01-08 11:53:55 +01:00
viky
5005f9198b fix: admin permission frontend side, should be able to see what owner can see 2026-01-06 23:52:40 +08:00
mhbdev
fe5efd7651 Added a built-in password generator to the shared input 2026-01-06 16:26:42 +03:30
mhbdev
8db7a421dc Made the deployments list items responsive by stacking the metadata/actions under the status on small screens, then restoring the side-by-side layout at sm and up. This keeps the date/duration and buttons from being squeezed or pushed off-screen in narrow widths. 2026-01-06 16:04:19 +03:30
Mauricio Siu
068deecb61 Merge pull request #3401 from bdkopen/remove-hi-base32-package
chore: uninstall `hi-base32` package
2026-01-05 22:44:50 -06:00
Mauricio Siu
9aa03efd13 Merge pull request #3402 from bdkopen/remove-otpauth-package
chore: uninstall `otpauth` package
2026-01-05 22:44:37 -06:00
bdkopen
016aa0248a chore: uninstall unused otpauth package 2026-01-05 22:27:57 -05:00
bdkopen
eb9d140c5d chore: uninstall ununused hi-base32 package 2026-01-05 21:13:25 -05:00
Tanmay Pathak
2eb73b988b feat(preview): add manual rebuild option for preview deployments 2026-01-04 15:24:25 -06:00
Mauricio Siu
d2ce587494 feat(compose): include composeId in deployment and redeployment responses close https://github.com/Dokploy/dokploy/issues/3359 2026-01-04 11:08:11 -06:00
Mauricio Siu
13ad8cb846 Merge pull request #3371 from mcfdez/feat/solid-color-avatars
feat: add solid colors for avatar
2026-01-04 11:07:13 -06:00
autofix-ci[bot]
0897417d7c [autofix.ci] apply automated fixes 2026-01-04 17:01:40 +00:00
Marc Fernandez
eb14a68bdd feat: add solid colors for avatar 2025-12-31 08:58:25 +01:00
Fitiavana Anhy Krishna
01c0b461b5 fix(selectAIProvider): add authorization header for Gemini provider 2025-12-31 10:13:20 +03:00
Mauricio Siu
9498fbeff3 Update package.json 2025-12-31 00:28:03 -06:00
Mauricio Siu
d2aa60ddf7 Update package.json 2025-12-30 23:53:30 -06:00
Mauricio Siu
58b75205af Merge pull request #3327 from Dokploy/refactor/separate-settings-from-users-table
refactor(settings): migrate user settings to webServerSettings schema…
2025-12-28 13:21:55 -06:00
Mauricio Siu
9e03625586 refactor(auth): simplify trustedOrigins logic by removing redundant admin check and using optional chaining for settings access 2025-12-28 13:18:20 -06:00
Mauricio Siu
260efdc2bb Merge pull request #3353 from bdkopen/remove-rotating-file-stream
chore: uninstall `rotating-file-stream`
2025-12-28 13:09:34 -06:00
bdkopen
1b5bfe051d chore: uninstall rotating-file-stream 2025-12-27 12:33:39 -05:00
Mauricio Siu
e4384075f2 Merge pull request #3341 from dpulpeiro/fix/stack-registry-auth
fix: pass registry auth to stack deploy
2025-12-25 03:29:33 -06:00
Mauricio Siu
b355d44605 fix(web-server-settings): use optional chaining for safer ID access in update function 2025-12-24 12:24:27 -06:00
Daniel García Pulpeiro
f39aa23803 fix: pass registry auth to stack deploy 2025-12-23 22:37:00 +01:00
Mauricio Siu
3abc4cdc3b refactor(access-log): consolidate web server settings imports and enhance log cleanup status retrieval 2025-12-21 01:46:27 -06:00
Mauricio Siu
ec56062f17 fix(settings): update getIp function to return an empty string for cloud environments 2025-12-21 01:45:49 -06:00
Mauricio Siu
10c4f882a5 Update packages/server/src/services/web-server-settings.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-21 01:44:46 -06:00
Mauricio Siu
f1dfa9c6a2 refactor(preview-deployment): remove dynamic import of getWebServerSettings and streamline IP retrieval logic 2025-12-21 01:43:09 -06:00
Mauricio Siu
6010643d9e refactor(server): update server configuration handling to utilize webServerSettings schema and improve code clarity 2025-12-21 01:41:33 -06:00
Mauricio Siu
1ccb205495 fix(admin): add optional chaining to safely access settings properties 2025-12-21 01:35:21 -06:00
autofix-ci[bot]
b2be5bc09f [autofix.ci] apply automated fixes 2025-12-21 07:33:59 +00:00
Mauricio Siu
babd30a110 refactor(settings): migrate user settings to webServerSettings schema and update related components 2025-12-21 01:33:18 -06:00
Mauricio Siu
e77f276785 refactor(issue-template): remove unnecessary dropdowns for git providers in bug report 2025-12-21 01:06:12 -06:00
Mauricio Siu
78c9a047b0 feat(issue-template): add dropdowns for affected areas and git providers in bug report 2025-12-21 01:05:33 -06:00
Mauricio Siu
84e0f5856b Merge pull request #3164 from difagume/fix/log-warning-detection
fix(docker-logs): fix warning symbol detection
2025-12-20 23:02:00 -06:00
Mauricio Siu
2bfa4643fc Merge pull request #3186 from divaltor/slider-resources
feat(resources): Add number component to have better UX control over Docker resources
2025-12-20 22:56:54 -06:00
Mauricio Siu
8c7bc82712 Merge pull request #3323 from Dokploy/copilot/fix-shell-command-issue
fix: quote registry username in docker login to prevent shell variable expansion
2025-12-20 21:24:57 -06:00
copilot-swe-agent[bot]
44645a6fbe fix: properly quote registry username in docker login to handle special characters like $
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 19:41:56 +00:00
copilot-swe-agent[bot]
771d0dd8ab Initial plan 2025-12-20 19:35:55 +00:00
Mauricio Siu
67725759e6 Merge pull request #3318 from Dokploy/copilot/fix-perplexity-ai-models-endpoint
Fix Perplexity AI provider models endpoint by returning hardcoded model list
2025-12-20 13:34:55 -06:00
Mauricio Siu
2065372d4f fix: update test command in package.json to remove specific test target 2025-12-20 13:34:32 -06:00
Amir Moradi
67d5e1a350 Update Docker version in server setup script 2025-12-20 07:46:31 +01:00
Amir Moradi
93fa19213e Downgrade package manager version to pnpm@9.12.0 2025-12-20 07:45:48 +01:00
Amir Moradi
1988a14b24 Downgrade package manager version to pnpm@9.12.0 2025-12-20 07:45:24 +01:00
Amir Moradi
3bdf029155 Downgrade pnpm version in package.json 2025-12-20 07:44:51 +01:00
Amir Moradi
e1896c2498 Downgrade pnpm version in package.json 2025-12-20 07:44:22 +01:00
Amir Moradi
a8064afd60 Downgrade pnpm version in package.json 2025-12-20 07:43:50 +01:00
Amir Moradi
3849a206e8 Downgrade pnpm version in Dockerfile.server 2025-12-20 07:43:23 +01:00
copilot-swe-agent[bot]
69d5c6f0cb Fix Perplexity AI provider by adding hardcoded model list
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:43:11 +00:00
Amir Moradi
bb0a53d976 Downgrade pnpm version in Dockerfile.schedule 2025-12-20 07:43:00 +01:00
Amir Moradi
0a8753d0a9 Update pnpm version in Dockerfile.cloud 2025-12-20 07:42:30 +01:00
Amir Moradi
23b14cf0cf Update pnpm and Docker versions in Dockerfile
Updated pnpm version from 9.15.9 to 9.12.0 and Docker version from 29.1.3 to 28.5.2.
2025-12-20 07:41:10 +01:00
copilot-swe-agent[bot]
53f67c6eb2 Initial plan 2025-12-20 06:38:09 +00:00
Mauricio Siu
7c53a3ef75 Merge pull request #3316 from Dokploy/feat/add-admin-creation-projects
fix: update project handling permissions to include admin role
2025-12-19 23:26:44 -06:00
Mauricio Siu
c065c85ee6 fix: update project handling permissions to include admin role 2025-12-19 23:26:12 -06:00
Mauricio Siu
db97de2a39 Merge pull request #3255 from odedd/feat/all-timezones-support
feat(schedules): add support for all IANA timezones
2025-12-19 23:23:27 -06:00
Mauricio Siu
dc7af1b840 Merge pull request #3269 from gosangam/canary
fix: return database instance as response on db creation (mongo, mysq…
2025-12-19 23:21:57 -06:00
Mauricio Siu
97362da2ae Merge pull request #3303 from draconisNoctis/feature/Fix-Disabling-of-Require-Collaborator-Permissions-Form
fix: disabling of previewRequireCollaboratorPermissions
2025-12-19 23:19:25 -06:00
Mauricio Siu
b476e50ff1 Merge pull request #3229 from fir4tozden/fix/some-fixes-in-dockerSafeExec
fix: some fixes in dockerSafeExec()
2025-12-19 23:10:59 -06:00
Mauricio Siu
1b22384315 Merge pull request #3267 from fir4tozden/bug-fix/volume-cleaning-should-not-be-performed
[CRITICAL] fix: volume cleaning should not be performed
2025-12-19 23:10:31 -06:00
Mauricio Siu
6685bd618e chore: update dokploy version to v0.26.3 and modify test command 2025-12-19 11:53:27 -06:00
Mauricio Siu
f5d334244a Merge pull request #3309 from Bima42/fix/3308-cannot-update-s3-endpoint
fix: invalidate query missing for s3 destination
2025-12-19 10:39:00 -06:00
Bima42
fd084c6d37 fix: invalidate query missing 2025-12-19 10:07:20 +01:00
Mark Wecke
e607220bfa fix: disabling of previewRequireCollaboratorPermissions 2025-12-18 15:37:51 +01:00
Mauricio Siu
d8514b067b Merge pull request #3273 from ayham291/mongo-replica
fix(mongo): use appName instead of localhost for replica set
2025-12-18 00:26:29 -06:00
Mauricio Siu
0590e78854 Merge pull request #3270 from Bima42/3165-add-environment-switch-dropdown
feat: being able to switch environments from breadcrumbs
2025-12-18 00:20:00 -06:00
Mauricio Siu
27fa0e881a Merge pull request #3298 from Dokploy/3230-build-server---doesnt-use-registry
feat(registry): improve server selection by categorizing deploy and b…
2025-12-17 23:07:29 -06:00
Mauricio Siu
72f2cc6268 feat(registry): improve server selection by categorizing deploy and build servers
- Refactored server data handling to separate deploy and build servers.
- Updated the UI to display servers in distinct groups for better clarity.
- Enhanced the server selection experience by dynamically rendering server options based on availability.
2025-12-17 23:06:21 -06:00
Mauricio Siu
854bd88e0a Merge pull request #3292 from Dokploy/3261-the-registry-password-is-always-blank-when-you-modify-any-existing-registry
feat(registry): enhance registry handling with optional password and …
2025-12-16 22:09:24 -06:00
autofix-ci[bot]
acf385a1f3 [autofix.ci] apply automated fixes 2025-12-17 04:08:36 +00:00
Mauricio Siu
d1bc109697 feat(registry): enhance registry handling with optional password and new test functionality
- Updated the AddRegistrySchema to make the password field optional when editing an existing registry.
- Introduced a new mutation, testRegistryById, to validate registry credentials using existing data.
- Improved form handling to conditionally require the password based on the editing state.
- Enhanced user feedback for registry testing with clearer error messages and instructions.
2025-12-16 22:07:52 -06:00
Mauricio Siu
38c7e1e996 Merge pull request #3276 from Divkix/fix-3268
fix(api): return database object from create endpoints
2025-12-16 21:50:15 -06:00
Mauricio Siu
54d5266573 Merge pull request #3291 from Dokploy/feat/use-cards-in-remote-servers
Feat/use cards in remote servers
2025-12-16 21:14:26 -06:00
autofix-ci[bot]
3a5ac9d31f [autofix.ci] apply automated fixes 2025-12-17 03:09:23 +00:00
Mauricio Siu
0ddf6b851f feat(servers): add tooltip for deactivated server status in dashboard
- Wrapped server status display in a TooltipProvider to provide additional context for deactivated servers.
- Implemented a tooltip that informs users about the reason for deactivation and instructions for reactivation, enhancing user experience and clarity in server management.
2025-12-16 21:05:52 -06:00
Amir Moradi
ed701df6ac Downgrade package manager to pnpm@9.15.9 2025-12-17 01:38:03 +01:00
Amir Moradi
dfc15cd621 Downgrade pnpm version in package.json 2025-12-17 01:37:11 +01:00
Amir Moradi
1ac3d1c1b0 Downgrade pnpm version in package.json 2025-12-17 01:36:40 +01:00
Amir Moradi
f6b756e711 Downgrade pnpm version in package.json 2025-12-17 01:36:05 +01:00
Amir Moradi
9f84dd4e0d Downgrade pnpm version in package.json 2025-12-17 01:35:12 +01:00
Amir Moradi
2e32b0a4af Update pnpm version in Dockerfile.server 2025-12-17 01:34:01 +01:00
Amir Moradi
0f69bbbd20 Downgrade pnpm version to 9.15.9 2025-12-17 01:33:36 +01:00
Amir Moradi
9e79314ef4 Downgrade pnpm version to 9.15.9 2025-12-17 01:33:14 +01:00
Amir Moradi
540b4039ac use pnpm 9.15.9 2025-12-17 01:32:59 +01:00
Claude
9e89edf167 chore(deps): update all tool versions across the codebase
Update to latest stable versions:
- pnpm: 9.12.0 → 10.26.0
- Docker: 28.5.0/28.5.2 → 29.1.3
- Nixpacks: 1.39.0 → 1.41.0
- Railpack: 0.2.2/0.15.0 → 0.15.1
- buildpacks/pack: 0.35.0 → 0.39.1

Files updated:
- All Dockerfiles (main, schedule, cloud, server)
- All package.json files (root, server, api, schedules, dokploy)
- GitHub workflow (pull-request.yml)
- Server setup script
- Database schema and DBML files
- Test fixtures
- UI components
2025-12-16 21:06:40 +00:00
Claude
e31d5a723b chore(deps): update Dockerfile dependencies to latest versions
- pnpm: 9.12.0 → 10.26.0
- Docker: 28.5.2 → 29.1.3
- Nixpacks: 1.39.0 → 1.41.0
- Railpack: 0.2.2 → 0.15.1
- buildpacks/pack: 0.35.0 → 0.39.1
2025-12-16 20:44:11 +00:00
Mauricio Siu
eb4fbff1b2 feat(servers): enhance server management UI with button options
- Added `asButton` prop to `HandleServers`, `SetupServer`, `ShowServerActions`, and `TerminalModal` components to allow rendering as buttons for improved UI flexibility.
- Updated the server management interface to use buttons for actions like editing and setting up servers, enhancing user experience.
- Introduced new icons for better visual representation of actions in the server management dashboard.
2025-12-15 15:17:56 -06:00
Bima42
3aeb52810c fix: missing switch env for apps 2025-12-15 10:10:12 +01:00
Divanshu Chauhan
8eaf2ab5c7 fix(api): return database object from create endpoints
Database creation APIs (mysql, mariadb, postgres, mongo) now return
the created database object with databaseID instead of boolean true.
This enables automation workflows to deploy databases immediately
after creation.

Fixes #3268
2025-12-15 11:56:39 +05:30
Mauricio Siu
5ebcbf86ea Merge pull request #3275 from Dokploy/3274-null-server-ip
fix(auth): update admin check to safely access user property
2025-12-15 00:24:03 -06:00
Mauricio Siu
67f4ca2cd9 fix(auth): update admin check to safely access user property
- Modified the admin check to use optional chaining, ensuring that the user property is accessed only if it exists, preventing potential runtime errors.
2025-12-15 00:23:43 -06:00
ayham291
6bb5404f87 fix(mongo): use appName instead of localhost for replica set
localhost doesn't work properly in containers
2025-12-15 00:05:38 +01:00
Bima42
3e356e6890 feat: being able to switch environments in sidebar 2025-12-14 17:01:44 +01:00
gosangam
b65f53d141 fix: return database instance as response on db creation (mongo, mysql, mariadb & postgres) 2025-12-14 20:31:05 +05:30
autofix-ci[bot]
2b1a3db7b8 [autofix.ci] apply automated fixes 2025-12-14 05:20:20 +00:00
фырат ёздэн
b66156956a fix: typing 2025-12-14 08:20:00 +03:00
autofix-ci[bot]
669de0f95f [autofix.ci] apply automated fixes 2025-12-14 05:16:30 +00:00
фырат ёздэн
371cf83e52 fix: typing 2025-12-14 08:16:09 +03:00
фырат ёздэн
51abf49458 chore: update pr id 2025-12-14 08:13:02 +03:00
Mauricio Siu
72cc7a2d2c Merge pull request #3265 from Dokploy/feat/templates-processor-allow-empty-variables-references
test(helpers): add tests for handling empty and undefined string vari…
2025-12-13 23:12:10 -06:00
autofix-ci[bot]
ba5283039c [autofix.ci] apply automated fixes 2025-12-14 05:11:51 +00:00
фырат ёздэн
19a7a80d43 [BUG] fix: volume cleaning should not be performed 2025-12-14 08:06:55 +03:00
фырат ёздэн
5d42737943 cepte 2025-12-14 07:32:28 +03:00
фырат ёздэн
4c10056394 chore 2025-12-14 07:24:27 +03:00
Mauricio Siu
d875e08d48 test(helpers): add tests for handling empty and undefined string variables in templates
- Introduced new test cases to verify the behavior of the `processValue` function when dealing with empty string variables and undefined variables.
- Ensured that empty strings are correctly replaced and undefined variables remain unchanged in the output.
2025-12-13 15:05:57 -06:00
Mauricio Siu
0b45b795e8 Merge pull request #3259 from Dokploy/2680-webhook-deployments-do-not-return-a-200-ok-causing-being-repeated-over-and-over
refactor(deploy): execute deployments in background to prevent timeouts
2025-12-13 01:30:36 -06:00
Mauricio Siu
d187b52e09 refactor(deploy): execute deployments in background to prevent timeouts
- Updated deployment logic across multiple API routes to run deployments in the background, allowing for immediate response and avoiding potential webhook timeouts.
- Added error handling to log any failures during background deployment.
2025-12-13 01:28:19 -06:00
Mauricio Siu
5f13679a97 Merge pull request #3258 from Dokploy/fix/long-request-on-cleanup
Fix/long request on cleanup
2025-12-13 00:58:50 -06:00
Mauricio Siu
415327c246 fix(storage): enhance success message for cleaning action to include a wait prompt 2025-12-13 00:58:21 -06:00
Mauricio Siu
12b8f8a4fd fix(storage): update success message for cleaning action 2025-12-13 00:58:07 -06:00
Mauricio Siu
fea3ec9a6f feat(cleanup): implement background cleanup functionality
- Added a new `cleanupAllBackground` function to execute Docker cleanup commands in the background, allowing for immediate return and avoiding gateway timeouts.
- Refactored existing cleanup functions to utilize a centralized `cleanupCommands` object for better maintainability and readability.
2025-12-13 00:57:41 -06:00
Mauricio Siu
2976bb5cf7 Merge pull request #3257 from Dokploy/fix/add-remove-build-registry
fix(build-server): enforce selection rules for Build Server and Build…
2025-12-13 00:48:09 -06:00
Mauricio Siu
092afbe1fa fix(dashboard): update project environment link to use default production environment
- Modified the project environment link to reference the default production environment instead of the first environment in the list, improving accuracy in navigation.
2025-12-13 00:32:26 -06:00
Mauricio Siu
a32e7e0041 fix(build-server): enforce selection rules for Build Server and Build Registry
- Updated validation schema to require that both Build Server and Build Registry must be selected together or both set to None.
- Added informational alert to guide users on the selection requirements.
- Enhanced onChange handlers to reset the corresponding field when one is set to "none".
2025-12-13 00:04:14 -06:00
Oded Davidov
c045c5328f feat(schedules): add support for all IANA timezones
- Replace limited 15-timezone list with comprehensive 421 IANA timezones
- Add searchable timezone selector with region grouping for better UX
- Create dedicated timezones.ts file following project conventions
- Support all timezone offsets including 30-min and 45-min offsets

Closes #2935

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 23:07:03 +02:00
Mauricio Siu
ee9edd7ff4 chore(version): bump dokploy version to v0.26.2 2025-12-12 10:55:42 -06:00
Mauricio Siu
3799aeab74 Merge pull request #3252 from Dokploy/3231-env-file-is-generated-in-dockerfile-directory
fix(environment): clarify .env file creation instructions
2025-12-12 10:24:26 -06:00
Mauricio Siu
4f6eb51c06 fix(environment): clarify .env file creation instructions
- Updated the description for the environment file creation option to specify that the .env file will be created in the same directory as the Dockerfile during the build process, enhancing user understanding.
2025-12-12 10:23:47 -06:00
Mauricio Siu
7cf898dcf6 Merge pull request #3251 from Dokploy/3247-cannot-edit-production-environment-variables
fix(environment): prevent renaming of the default environment
2025-12-12 10:15:42 -06:00
Mauricio Siu
1c83919408 fix(environment): prevent deletion of the default environment
- Added logic to disallow deletion of the default environment, throwing a BAD_REQUEST error if an attempt is made to delete it.
2025-12-12 10:15:16 -06:00
Mauricio Siu
b230687c8a fix(environment): prevent renaming of the default environment
- Updated the logic to disallow renaming the default environment while still allowing updates to its description and other properties.
- Adjusted error message for clarity when attempting to rename the default environment.
2025-12-12 10:14:03 -06:00
Mauricio Siu
b499cefebc Merge pull request #3250 from Dokploy/3249-cant-enable-volume-backup-notifications-on-new-custom-webhook-notifications
chore(dependencies): update Next.js to version 16.0.10 and remove tur…
2025-12-12 10:12:09 -06:00
Mauricio Siu
a04a4c05ea chore(dependencies): update Next.js to version 16.0.10 and remove turbopack script from package.json
- Updated Next.js version in both root and dokploy package.json files to 16.0.10 for improved performance and features.
- Removed the turbopack development script from the dokploy package.json to streamline the development process.
- Added volumeBackup property to notification handling in multiple files for enhanced backup options.
2025-12-12 10:09:31 -06:00
фырат ёздэн
8c889fc71e fix: some fixes in dockerSafeExec() 2025-12-10 22:06:55 +03:00
Mauricio Siu
e7dc05d031 Merge pull request #3221 from AbdenourTadjer33/patch-1
fix(backups): optional chaining for logCleanupCron
2025-12-10 12:06:48 -06:00
Abdenour Tadjer
9544b2ace3 fix(backups): optional chaining for logCleanupCron 2025-12-10 09:36:17 +01:00
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
Vlad Vladov
d465fb4da1 feat(resources): Add number component to have better UX control over Docker resources 2025-12-07 13:45:14 +02: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
diego fabricio
698104e7b7 fix(docker-logs): fix warning symbol detection
- Added support for detecting warning symbols (⚠, ⚠️) in log messages
2025-12-04 11:33:29 -05: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
214 changed files with 78972 additions and 1834 deletions

BIN
.github/sponsors/awesome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -24,14 +24,14 @@ jobs:
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.39.0
export NIXPACKS_VERSION=1.41.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.0
export RAILPACK_VERSION=0.15.4
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"

5
.gitignore vendored
View File

@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
# Development environment
.devcontainer

View File

@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request

View File

@@ -51,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.39.0
ARG NIXPACKS_VERSION=1.41.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.2.2
ARG RAILPACK_VERSION=0.15.4
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]

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
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
RUN pnpm install -g tsx
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]

View File

@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
COPY --from=build /prod/schedules/package.json ./package.json
COPY --from=build /prod/schedules/node_modules ./node_modules
CMD HOSTNAME=0.0.0.0 && pnpm start
CMD HOSTNAME=0.0.0.0 && pnpm start

View File

@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
CMD HOSTNAME=0.0.0.0 && pnpm start
CMD HOSTNAME=0.0.0.0 && pnpm start

View File

@@ -80,7 +80,9 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
</a>
<a href="https://awesome.tools/" target="_blank">
<img src=".github/sponsors/awesome.png" width="200" height="150" />
</a>
</div>
<!-- Premium Supporters 🥇 -->

View File

@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"pino": "9.4.0",

View File

@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),

View File

@@ -4,6 +4,7 @@ import {
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",

View File

@@ -0,0 +1,243 @@
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");
});
});
describe("special characters in username", () => {
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
const registry = createMockRegistry({
username: "robot$library+dokploy",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
});
it("should handle username with $ and other special characters", () => {
const registry = createMockRegistry({
username: "robot$test+app",
});
const result = getRegistryTag(registry, "myapp:latest");
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
});
it("should handle username with multiple $ symbols", () => {
const registry = createMockRegistry({
username: "user$name$test",
});
const result = getRegistryTag(registry, "app");
expect(result).toBe("docker.io/user$name$test/app");
});
it("should handle username with + and - symbols", () => {
const registry = createMockRegistry({
username: "robot+test-user",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
});
});
});

View File

@@ -0,0 +1,215 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
/**
* 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

@@ -25,9 +25,10 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
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

@@ -161,6 +161,50 @@ describe("helpers functions", () => {
});
});
describe("Empty string variables", () => {
it("should replace variables with empty string values correctly", () => {
const variables = {
smtp_username: "",
smtp_password: "",
non_empty: "value",
};
const result1 = processValue("${smtp_username}", variables, mockSchema);
expect(result1).toBe("");
const result2 = processValue("${smtp_password}", variables, mockSchema);
expect(result2).toBe("");
const result3 = processValue("${non_empty}", variables, mockSchema);
expect(result3).toBe("value");
});
it("should not replace undefined variables", () => {
const variables = {
defined_var: "",
};
const result = processValue("${undefined_var}", variables, mockSchema);
expect(result).toBe("${undefined_var}");
});
it("should handle mixed empty and non-empty variables in template", () => {
const variables = {
smtp_address: "smtp.example.com",
smtp_port: "2525",
smtp_username: "",
smtp_password: "",
};
const template =
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
const result = processValue(template, variables, mockSchema);
expect(result).toBe(
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
);
});
});
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);

View File

@@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig, User } from "@dokploy/server";
import type { FileConfig } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
import type { webServerSettings } from "@dokploy/server/db/schema";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
type WebServerSettings = typeof webServerSettings.$inferSelect;
const baseSettings: WebServerSettings = {
id: "",
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
certificateType: "none",
host: null,
serverIp: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
metricsConfig: {
containers: {
refreshRate: 20,
@@ -43,30 +51,8 @@ const baseAdmin: User = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
createdAt: null,
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -84,7 +70,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
...baseSettings,
https: true,
certificateType: "letsencrypt",
},
@@ -99,7 +85,7 @@ test("Should apply redirect-to-https", () => {
});
test("Should change only host when no certificate", () => {
updateServerTraefik(baseAdmin, "example.com");
updateServerTraefik(baseSettings, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -109,7 +95,7 @@ test("Should change only host when no certificate", () => {
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(baseAdmin, null);
updateServerTraefik(baseSettings, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -118,11 +104,14 @@ test("Should not touch config without host", () => {
test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
{ ...baseAdmin, certificateType: "letsencrypt" },
{ ...baseSettings, certificateType: "letsencrypt" },
"example.com",
);
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
updateServerTraefik(
{ ...baseSettings, certificateType: "none" },
"example.com",
);
const config: FileConfig = loadOrCreateConfig("dokploy");

View File

@@ -3,10 +3,11 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
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

@@ -38,10 +38,31 @@ interface Props {
applicationId: string;
}
const schema = z.object({
buildServerId: z.string().min(1, "Build server is required"),
buildRegistryId: z.string().min(1, "Build registry is required"),
});
const schema = z
.object({
buildServerId: z.string().optional(),
buildRegistryId: z.string().optional(),
})
.refine(
(data) => {
// Both empty/none is valid
const buildServerIsNone =
!data.buildServerId || data.buildServerId === "none";
const buildRegistryIsNone =
!data.buildRegistryId || data.buildRegistryId === "none";
// Both should be either filled or empty
if (buildServerIsNone && buildRegistryIsNone) return true;
if (!buildServerIsNone && !buildRegistryIsNone) return true;
return false;
},
{
message:
"Both Build Server and Build Registry must be selected together, or both set to None",
path: ["buildServerId"], // Show error on buildServerId field
},
);
type Schema = z.infer<typeof schema>;
@@ -121,6 +142,11 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
container starts running.
</AlertBlock>
<AlertBlock type="info">
<strong>Note:</strong> Build Server and Build Registry must be
configured together. You can either select both or set both to None.
</AlertBlock>
{!registries || registries.length === 0 ? (
<AlertBlock type="warning">
You need to add at least one registry to use build servers. Please
@@ -147,7 +173,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Build Server</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
// If setting to "none", also reset build registry to "none"
if (value === "none") {
form.setValue("buildRegistryId", "none");
}
}}
value={field.value || "none"}
>
<FormControl>
@@ -197,7 +229,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Build Registry</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
// If setting to "none", also reset build server to "none"
if (value === "none") {
form.setValue("buildServerId", "none");
}
}}
value={field.value || "none"}
>
<FormControl>

View File

@@ -21,7 +21,10 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
createConverter,
NumberInputWithSteps,
} from "@/components/ui/number-input";
import {
Tooltip,
TooltipContent,
@@ -30,6 +33,23 @@ import {
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const CPU_STEP = 0.25;
const MEMORY_STEP_MB = 256;
const formatNumber = (value: number, decimals = 2): string =>
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
);
const memoryConverter = createConverter(1024 * 1024, (mb) => {
if (mb <= 0) return "";
return mb >= 1024
? `${formatNumber(mb / 1024)} GB`
: `${formatNumber(mb)} MB`;
});
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
@@ -51,6 +71,7 @@ interface Props {
}
type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory hard limit in bytes. Example: 1GB =
1073741824 bytes
1073741824 bytes. Use +/- buttons to adjust by
256 MB.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="1073741824 (1GB in bytes)"
{...field}
step={MEMORY_STEP_MB}
converter={memoryConverter}
/>
</FormControl>
<FormMessage />
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
268435456 bytes. Use +/- buttons to adjust by 256
MB.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="268435456 (256MB in bytes)"
{...field}
step={MEMORY_STEP_MB}
converter={memoryConverter}
/>
</FormControl>
<FormMessage />
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU quota in units of 10^-9 CPUs. Example: 2
CPUs = 2000000000
CPUs = 2000000000. Use +/- buttons to adjust by
0.25 CPU.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="2000000000 (2 CPUs)"
{...field}
value={field.value?.toString() || ""}
step={CPU_STEP}
converter={cpuConverter}
/>
</FormControl>
<FormMessage />
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU shares (relative weight). Example: 1 CPU =
1000000000
1000000000. Use +/- buttons to adjust by 0.25
CPU.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input placeholder="1000000000 (1 CPU)" {...field} />
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="1000000000 (1 CPU)"
step={CPU_STEP}
converter={cpuConverter}
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,8 +20,39 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;
export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.2.2"),
railpackVersion: z.string().nullable().default("0.15.4"),
}),
z.object({
buildType: z.literal(BuildType.static),
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
if (data) {
@@ -163,6 +196,14 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};
form.reset(resetData(typedData));
// Check if railpack version is manual (not in the predefined list)
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
) {
setIsManualRailpackVersion(true);
}
}
}, [data, form]);
@@ -186,7 +227,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.2.2"
? data.railpackVersion || "0.15.4"
: null,
})
.then(async () => {
@@ -403,23 +444,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
/>
)}
{buildType === BuildType.railpack && (
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
<Input
placeholder="Railpack Version"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
{isManualRailpackVersion ? (
<div className="space-y-2">
<Input
placeholder="Enter custom version (e.g., 0.15.4)"
{...field}
value={field.value ?? ""}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
}}
>
Use predefined versions
</Button>
</div>
) : (
<Select
onValueChange={(value) => {
if (value === "manual") {
setIsManualRailpackVersion(true);
field.onChange("");
} else {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
>
Latest
</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormControl>
<FormDescription>
Select a Railpack version or choose manual to enter a
custom version.{" "}
<a
href="https://github.com/railwayapp/railpack/releases"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
View releases
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">

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} />
)}
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
return (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col">
<div className="flex flex-1 flex-col min-w-0">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-row items-center gap-2">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
variant="destructive"
size="sm"
isLoading={isKillingProcess}
className="w-full sm:w-auto"
>
Kill Process
</Button>
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
onClick={() => {
setActiveLog(deployment);
}}
className="w-full sm:w-auto"
>
View
</Button>
@@ -373,7 +375,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({
@@ -393,6 +407,7 @@ export const ShowDeployments = ({
variant="secondary"
size="sm"
isLoading={isRollingBack}
className="w-full sm:w-auto"
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
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,31 @@ 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 in the same
directory as your Dockerfile 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

@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

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

@@ -2,6 +2,7 @@ import {
ExternalLink,
FileText,
GitPullRequest,
Hammer,
Loader2,
PenSquare,
RocketIcon,
@@ -22,6 +23,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } =
api.previewDeployment.redeploy.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: (data) =>
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
},
);
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowDeploymentsModal>
<DialogAction
title="Rebuild Preview Deployment"
description="Are you sure you want to rebuild this preview deployment?"
type="default"
onClick={async () => {
await redeployPreviewDeployment({
previewDeploymentId:
deployment.previewDeploymentId,
})
.then(() => {
toast.success(
"Preview deployment rebuild started",
);
refetchPreviewDeployments();
})
.catch(() => {
toast.error(
"Error rebuilding preview deployment",
);
});
}}
>
<Button
variant="outline"
size="sm"
isLoading={status === "running"}
className="gap-2"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Hammer className="size-4" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent
sideOffset={5}
className="z-[60]"
>
<p>
Rebuild the preview deployment without
downloading new code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
</Button>
</DialogAction>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}

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);
@@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
data.previewRequireCollaboratorPermissions || true,
data.previewRequireCollaboratorPermissions ?? true,
});
}
}, [data]);
@@ -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

@@ -1,5 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
@@ -13,6 +15,14 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -31,6 +41,12 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -48,6 +64,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { getTimezoneLabel, TIMEZONES } from "./timezones";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
@@ -75,6 +92,7 @@ const formSchema = z
"dokploy-server",
]),
script: z.string(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
@@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
timezone: undefined,
},
});
@@ -251,6 +270,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 +484,89 @@ 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>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{getTimezoneLabel(field.value)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search timezone..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<ScrollArea className="h-72">
{Object.entries(TIMEZONES).map(
([region, zones]) => (
<CommandGroup key={region} heading={region}>
{zones.map((tz) => (
<CommandItem
key={tz.value}
value={`${region} ${tz.label} ${tz.value}`}
onSelect={() => {
field.onChange(tz.value);
}}
>
{tz.value}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
field.value === tz.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
),
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Optional: Choose a timezone for the schedule execution time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>

View File

@@ -0,0 +1,458 @@
// Complete list of IANA timezones grouped by region
export const TIMEZONES: Record<
string,
Array<{ label: string; value: string }>
> = {
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
Africa: [
{ label: "Abidjan", value: "Africa/Abidjan" },
{ label: "Accra", value: "Africa/Accra" },
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
{ label: "Algiers", value: "Africa/Algiers" },
{ label: "Asmara", value: "Africa/Asmara" },
{ label: "Bamako", value: "Africa/Bamako" },
{ label: "Bangui", value: "Africa/Bangui" },
{ label: "Banjul", value: "Africa/Banjul" },
{ label: "Bissau", value: "Africa/Bissau" },
{ label: "Blantyre", value: "Africa/Blantyre" },
{ label: "Brazzaville", value: "Africa/Brazzaville" },
{ label: "Bujumbura", value: "Africa/Bujumbura" },
{ label: "Cairo", value: "Africa/Cairo" },
{ label: "Casablanca", value: "Africa/Casablanca" },
{ label: "Ceuta", value: "Africa/Ceuta" },
{ label: "Conakry", value: "Africa/Conakry" },
{ label: "Dakar", value: "Africa/Dakar" },
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
{ label: "Djibouti", value: "Africa/Djibouti" },
{ label: "Douala", value: "Africa/Douala" },
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
{ label: "Freetown", value: "Africa/Freetown" },
{ label: "Gaborone", value: "Africa/Gaborone" },
{ label: "Harare", value: "Africa/Harare" },
{ label: "Johannesburg", value: "Africa/Johannesburg" },
{ label: "Juba", value: "Africa/Juba" },
{ label: "Kampala", value: "Africa/Kampala" },
{ label: "Khartoum", value: "Africa/Khartoum" },
{ label: "Kigali", value: "Africa/Kigali" },
{ label: "Kinshasa", value: "Africa/Kinshasa" },
{ label: "Lagos", value: "Africa/Lagos" },
{ label: "Libreville", value: "Africa/Libreville" },
{ label: "Lome", value: "Africa/Lome" },
{ label: "Luanda", value: "Africa/Luanda" },
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
{ label: "Lusaka", value: "Africa/Lusaka" },
{ label: "Malabo", value: "Africa/Malabo" },
{ label: "Maputo", value: "Africa/Maputo" },
{ label: "Maseru", value: "Africa/Maseru" },
{ label: "Mbabane", value: "Africa/Mbabane" },
{ label: "Mogadishu", value: "Africa/Mogadishu" },
{ label: "Monrovia", value: "Africa/Monrovia" },
{ label: "Nairobi", value: "Africa/Nairobi" },
{ label: "Ndjamena", value: "Africa/Ndjamena" },
{ label: "Niamey", value: "Africa/Niamey" },
{ label: "Nouakchott", value: "Africa/Nouakchott" },
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
{ label: "Tripoli", value: "Africa/Tripoli" },
{ label: "Tunis", value: "Africa/Tunis" },
{ label: "Windhoek", value: "Africa/Windhoek" },
],
America: [
{ label: "Adak", value: "America/Adak" },
{ label: "Anchorage", value: "America/Anchorage" },
{ label: "Anguilla", value: "America/Anguilla" },
{ label: "Antigua", value: "America/Antigua" },
{ label: "Araguaina", value: "America/Araguaina" },
{
label: "Argentina/Buenos Aires",
value: "America/Argentina/Buenos_Aires",
},
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
{
label: "Argentina/Rio Gallegos",
value: "America/Argentina/Rio_Gallegos",
},
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
{ label: "Aruba", value: "America/Aruba" },
{ label: "Asuncion", value: "America/Asuncion" },
{ label: "Atikokan", value: "America/Atikokan" },
{ label: "Bahia", value: "America/Bahia" },
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
{ label: "Barbados", value: "America/Barbados" },
{ label: "Belem", value: "America/Belem" },
{ label: "Belize", value: "America/Belize" },
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
{ label: "Boa Vista", value: "America/Boa_Vista" },
{ label: "Bogota", value: "America/Bogota" },
{ label: "Boise", value: "America/Boise" },
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
{ label: "Campo Grande", value: "America/Campo_Grande" },
{ label: "Cancun", value: "America/Cancun" },
{ label: "Caracas", value: "America/Caracas" },
{ label: "Cayenne", value: "America/Cayenne" },
{ label: "Cayman", value: "America/Cayman" },
{ label: "Chicago (Central Time)", value: "America/Chicago" },
{ label: "Chihuahua", value: "America/Chihuahua" },
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
{ label: "Costa Rica", value: "America/Costa_Rica" },
{ label: "Creston", value: "America/Creston" },
{ label: "Cuiaba", value: "America/Cuiaba" },
{ label: "Curacao", value: "America/Curacao" },
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
{ label: "Dawson", value: "America/Dawson" },
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
{ label: "Denver (Mountain Time)", value: "America/Denver" },
{ label: "Detroit", value: "America/Detroit" },
{ label: "Dominica", value: "America/Dominica" },
{ label: "Edmonton", value: "America/Edmonton" },
{ label: "Eirunepe", value: "America/Eirunepe" },
{ label: "El Salvador", value: "America/El_Salvador" },
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
{ label: "Fortaleza", value: "America/Fortaleza" },
{ label: "Glace Bay", value: "America/Glace_Bay" },
{ label: "Goose Bay", value: "America/Goose_Bay" },
{ label: "Grand Turk", value: "America/Grand_Turk" },
{ label: "Grenada", value: "America/Grenada" },
{ label: "Guadeloupe", value: "America/Guadeloupe" },
{ label: "Guatemala", value: "America/Guatemala" },
{ label: "Guayaquil", value: "America/Guayaquil" },
{ label: "Guyana", value: "America/Guyana" },
{ label: "Halifax", value: "America/Halifax" },
{ label: "Havana", value: "America/Havana" },
{ label: "Hermosillo", value: "America/Hermosillo" },
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
{ label: "Inuvik", value: "America/Inuvik" },
{ label: "Iqaluit", value: "America/Iqaluit" },
{ label: "Jamaica", value: "America/Jamaica" },
{ label: "Juneau", value: "America/Juneau" },
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
{ label: "Kralendijk", value: "America/Kralendijk" },
{ label: "La Paz", value: "America/La_Paz" },
{ label: "Lima", value: "America/Lima" },
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
{ label: "Lower Princes", value: "America/Lower_Princes" },
{ label: "Maceio", value: "America/Maceio" },
{ label: "Managua", value: "America/Managua" },
{ label: "Manaus", value: "America/Manaus" },
{ label: "Marigot", value: "America/Marigot" },
{ label: "Martinique", value: "America/Martinique" },
{ label: "Matamoros", value: "America/Matamoros" },
{ label: "Mazatlan", value: "America/Mazatlan" },
{ label: "Menominee", value: "America/Menominee" },
{ label: "Merida", value: "America/Merida" },
{ label: "Metlakatla", value: "America/Metlakatla" },
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
{ label: "Miquelon", value: "America/Miquelon" },
{ label: "Moncton", value: "America/Moncton" },
{ label: "Monterrey", value: "America/Monterrey" },
{ label: "Montevideo", value: "America/Montevideo" },
{ label: "Montserrat", value: "America/Montserrat" },
{ label: "Nassau", value: "America/Nassau" },
{ label: "New York (Eastern Time)", value: "America/New_York" },
{ label: "Nome", value: "America/Nome" },
{ label: "Noronha", value: "America/Noronha" },
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
{
label: "North Dakota/New Salem",
value: "America/North_Dakota/New_Salem",
},
{ label: "Nuuk", value: "America/Nuuk" },
{ label: "Ojinaga", value: "America/Ojinaga" },
{ label: "Panama", value: "America/Panama" },
{ label: "Paramaribo", value: "America/Paramaribo" },
{ label: "Phoenix", value: "America/Phoenix" },
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
{ label: "Port of Spain", value: "America/Port_of_Spain" },
{ label: "Porto Velho", value: "America/Porto_Velho" },
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
{ label: "Recife", value: "America/Recife" },
{ label: "Regina", value: "America/Regina" },
{ label: "Resolute", value: "America/Resolute" },
{ label: "Rio Branco", value: "America/Rio_Branco" },
{ label: "Santarem", value: "America/Santarem" },
{ label: "Santiago", value: "America/Santiago" },
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
{ label: "Scoresbysund", value: "America/Scoresbysund" },
{ label: "Sitka", value: "America/Sitka" },
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
{ label: "St Johns", value: "America/St_Johns" },
{ label: "St Kitts", value: "America/St_Kitts" },
{ label: "St Lucia", value: "America/St_Lucia" },
{ label: "St Thomas", value: "America/St_Thomas" },
{ label: "St Vincent", value: "America/St_Vincent" },
{ label: "Swift Current", value: "America/Swift_Current" },
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
{ label: "Thule", value: "America/Thule" },
{ label: "Tijuana", value: "America/Tijuana" },
{ label: "Toronto", value: "America/Toronto" },
{ label: "Tortola", value: "America/Tortola" },
{ label: "Vancouver", value: "America/Vancouver" },
{ label: "Whitehorse", value: "America/Whitehorse" },
{ label: "Winnipeg", value: "America/Winnipeg" },
{ label: "Yakutat", value: "America/Yakutat" },
],
Antarctica: [
{ label: "Casey", value: "Antarctica/Casey" },
{ label: "Davis", value: "Antarctica/Davis" },
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
{ label: "Macquarie", value: "Antarctica/Macquarie" },
{ label: "Mawson", value: "Antarctica/Mawson" },
{ label: "McMurdo", value: "Antarctica/McMurdo" },
{ label: "Palmer", value: "Antarctica/Palmer" },
{ label: "Rothera", value: "Antarctica/Rothera" },
{ label: "Syowa", value: "Antarctica/Syowa" },
{ label: "Troll", value: "Antarctica/Troll" },
{ label: "Vostok", value: "Antarctica/Vostok" },
],
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
Asia: [
{ label: "Aden", value: "Asia/Aden" },
{ label: "Almaty", value: "Asia/Almaty" },
{ label: "Amman", value: "Asia/Amman" },
{ label: "Anadyr", value: "Asia/Anadyr" },
{ label: "Aqtau", value: "Asia/Aqtau" },
{ label: "Aqtobe", value: "Asia/Aqtobe" },
{ label: "Ashgabat", value: "Asia/Ashgabat" },
{ label: "Atyrau", value: "Asia/Atyrau" },
{ label: "Baghdad", value: "Asia/Baghdad" },
{ label: "Bahrain", value: "Asia/Bahrain" },
{ label: "Baku", value: "Asia/Baku" },
{ label: "Bangkok", value: "Asia/Bangkok" },
{ label: "Barnaul", value: "Asia/Barnaul" },
{ label: "Beirut", value: "Asia/Beirut" },
{ label: "Bishkek", value: "Asia/Bishkek" },
{ label: "Brunei", value: "Asia/Brunei" },
{ label: "Chita", value: "Asia/Chita" },
{ label: "Choibalsan", value: "Asia/Choibalsan" },
{ label: "Colombo", value: "Asia/Colombo" },
{ label: "Damascus", value: "Asia/Damascus" },
{ label: "Dhaka", value: "Asia/Dhaka" },
{ label: "Dili", value: "Asia/Dili" },
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
{ label: "Dushanbe", value: "Asia/Dushanbe" },
{ label: "Famagusta", value: "Asia/Famagusta" },
{ label: "Gaza", value: "Asia/Gaza" },
{ label: "Hebron", value: "Asia/Hebron" },
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
{ label: "Hovd", value: "Asia/Hovd" },
{ label: "Irkutsk", value: "Asia/Irkutsk" },
{ label: "Jakarta", value: "Asia/Jakarta" },
{ label: "Jayapura", value: "Asia/Jayapura" },
{ label: "Jerusalem", value: "Asia/Jerusalem" },
{ label: "Kabul", value: "Asia/Kabul" },
{ label: "Kamchatka", value: "Asia/Kamchatka" },
{ label: "Karachi", value: "Asia/Karachi" },
{ label: "Kathmandu", value: "Asia/Kathmandu" },
{ label: "Khandyga", value: "Asia/Khandyga" },
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
{ label: "Kuching", value: "Asia/Kuching" },
{ label: "Kuwait", value: "Asia/Kuwait" },
{ label: "Macau", value: "Asia/Macau" },
{ label: "Magadan", value: "Asia/Magadan" },
{ label: "Makassar", value: "Asia/Makassar" },
{ label: "Manila", value: "Asia/Manila" },
{ label: "Muscat", value: "Asia/Muscat" },
{ label: "Nicosia", value: "Asia/Nicosia" },
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
{ label: "Omsk", value: "Asia/Omsk" },
{ label: "Oral", value: "Asia/Oral" },
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
{ label: "Pontianak", value: "Asia/Pontianak" },
{ label: "Pyongyang", value: "Asia/Pyongyang" },
{ label: "Qatar", value: "Asia/Qatar" },
{ label: "Qostanay", value: "Asia/Qostanay" },
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
{ label: "Riyadh", value: "Asia/Riyadh" },
{ label: "Sakhalin", value: "Asia/Sakhalin" },
{ label: "Samarkand", value: "Asia/Samarkand" },
{ label: "Seoul", value: "Asia/Seoul" },
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
{ label: "Singapore", value: "Asia/Singapore" },
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
{ label: "Taipei", value: "Asia/Taipei" },
{ label: "Tashkent", value: "Asia/Tashkent" },
{ label: "Tbilisi", value: "Asia/Tbilisi" },
{ label: "Tehran", value: "Asia/Tehran" },
{ label: "Thimphu", value: "Asia/Thimphu" },
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
{ label: "Tomsk", value: "Asia/Tomsk" },
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
{ label: "Urumqi", value: "Asia/Urumqi" },
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
{ label: "Vientiane", value: "Asia/Vientiane" },
{ label: "Vladivostok", value: "Asia/Vladivostok" },
{ label: "Yakutsk", value: "Asia/Yakutsk" },
{ label: "Yangon", value: "Asia/Yangon" },
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
{ label: "Yerevan", value: "Asia/Yerevan" },
],
Atlantic: [
{ label: "Azores", value: "Atlantic/Azores" },
{ label: "Bermuda", value: "Atlantic/Bermuda" },
{ label: "Canary", value: "Atlantic/Canary" },
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
{ label: "Faroe", value: "Atlantic/Faroe" },
{ label: "Madeira", value: "Atlantic/Madeira" },
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
{ label: "St Helena", value: "Atlantic/St_Helena" },
{ label: "Stanley", value: "Atlantic/Stanley" },
],
Australia: [
{ label: "Adelaide", value: "Australia/Adelaide" },
{ label: "Brisbane", value: "Australia/Brisbane" },
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
{ label: "Darwin", value: "Australia/Darwin" },
{ label: "Eucla", value: "Australia/Eucla" },
{ label: "Hobart", value: "Australia/Hobart" },
{ label: "Lindeman", value: "Australia/Lindeman" },
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
{ label: "Melbourne", value: "Australia/Melbourne" },
{ label: "Perth", value: "Australia/Perth" },
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
],
Europe: [
{ label: "Amsterdam", value: "Europe/Amsterdam" },
{ label: "Andorra", value: "Europe/Andorra" },
{ label: "Astrakhan", value: "Europe/Astrakhan" },
{ label: "Athens", value: "Europe/Athens" },
{ label: "Belgrade", value: "Europe/Belgrade" },
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
{ label: "Bratislava", value: "Europe/Bratislava" },
{ label: "Brussels", value: "Europe/Brussels" },
{ label: "Bucharest", value: "Europe/Bucharest" },
{ label: "Budapest", value: "Europe/Budapest" },
{ label: "Busingen", value: "Europe/Busingen" },
{ label: "Chisinau", value: "Europe/Chisinau" },
{ label: "Copenhagen", value: "Europe/Copenhagen" },
{ label: "Dublin", value: "Europe/Dublin" },
{ label: "Gibraltar", value: "Europe/Gibraltar" },
{ label: "Guernsey", value: "Europe/Guernsey" },
{ label: "Helsinki", value: "Europe/Helsinki" },
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
{ label: "Istanbul", value: "Europe/Istanbul" },
{ label: "Jersey", value: "Europe/Jersey" },
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
{ label: "Kirov", value: "Europe/Kirov" },
{ label: "Kyiv", value: "Europe/Kyiv" },
{ label: "Lisbon", value: "Europe/Lisbon" },
{ label: "Ljubljana", value: "Europe/Ljubljana" },
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
{ label: "Luxembourg", value: "Europe/Luxembourg" },
{ label: "Madrid", value: "Europe/Madrid" },
{ label: "Malta", value: "Europe/Malta" },
{ label: "Mariehamn", value: "Europe/Mariehamn" },
{ label: "Minsk", value: "Europe/Minsk" },
{ label: "Monaco", value: "Europe/Monaco" },
{ label: "Moscow", value: "Europe/Moscow" },
{ label: "Oslo", value: "Europe/Oslo" },
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
{ label: "Podgorica", value: "Europe/Podgorica" },
{ label: "Prague", value: "Europe/Prague" },
{ label: "Riga", value: "Europe/Riga" },
{ label: "Rome", value: "Europe/Rome" },
{ label: "Samara", value: "Europe/Samara" },
{ label: "San Marino", value: "Europe/San_Marino" },
{ label: "Sarajevo", value: "Europe/Sarajevo" },
{ label: "Saratov", value: "Europe/Saratov" },
{ label: "Simferopol", value: "Europe/Simferopol" },
{ label: "Skopje", value: "Europe/Skopje" },
{ label: "Sofia", value: "Europe/Sofia" },
{ label: "Stockholm", value: "Europe/Stockholm" },
{ label: "Tallinn", value: "Europe/Tallinn" },
{ label: "Tirane", value: "Europe/Tirane" },
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
{ label: "Vaduz", value: "Europe/Vaduz" },
{ label: "Vatican", value: "Europe/Vatican" },
{ label: "Vienna", value: "Europe/Vienna" },
{ label: "Vilnius", value: "Europe/Vilnius" },
{ label: "Volgograd", value: "Europe/Volgograd" },
{ label: "Warsaw", value: "Europe/Warsaw" },
{ label: "Zagreb", value: "Europe/Zagreb" },
{ label: "Zurich", value: "Europe/Zurich" },
],
Indian: [
{ label: "Antananarivo", value: "Indian/Antananarivo" },
{ label: "Chagos", value: "Indian/Chagos" },
{ label: "Christmas", value: "Indian/Christmas" },
{ label: "Cocos", value: "Indian/Cocos" },
{ label: "Comoro", value: "Indian/Comoro" },
{ label: "Kerguelen", value: "Indian/Kerguelen" },
{ label: "Mahe", value: "Indian/Mahe" },
{ label: "Maldives", value: "Indian/Maldives" },
{ label: "Mauritius", value: "Indian/Mauritius" },
{ label: "Mayotte", value: "Indian/Mayotte" },
{ label: "Reunion", value: "Indian/Reunion" },
],
Pacific: [
{ label: "Apia", value: "Pacific/Apia" },
{ label: "Auckland", value: "Pacific/Auckland" },
{ label: "Bougainville", value: "Pacific/Bougainville" },
{ label: "Chatham", value: "Pacific/Chatham" },
{ label: "Chuuk", value: "Pacific/Chuuk" },
{ label: "Easter", value: "Pacific/Easter" },
{ label: "Efate", value: "Pacific/Efate" },
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
{ label: "Fiji", value: "Pacific/Fiji" },
{ label: "Funafuti", value: "Pacific/Funafuti" },
{ label: "Galapagos", value: "Pacific/Galapagos" },
{ label: "Gambier", value: "Pacific/Gambier" },
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
{ label: "Guam", value: "Pacific/Guam" },
{ label: "Honolulu", value: "Pacific/Honolulu" },
{ label: "Kanton", value: "Pacific/Kanton" },
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
{ label: "Kosrae", value: "Pacific/Kosrae" },
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
{ label: "Majuro", value: "Pacific/Majuro" },
{ label: "Marquesas", value: "Pacific/Marquesas" },
{ label: "Midway", value: "Pacific/Midway" },
{ label: "Nauru", value: "Pacific/Nauru" },
{ label: "Niue", value: "Pacific/Niue" },
{ label: "Norfolk", value: "Pacific/Norfolk" },
{ label: "Noumea", value: "Pacific/Noumea" },
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
{ label: "Palau", value: "Pacific/Palau" },
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
{ label: "Saipan", value: "Pacific/Saipan" },
{ label: "Tahiti", value: "Pacific/Tahiti" },
{ label: "Tarawa", value: "Pacific/Tarawa" },
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
{ label: "Wake", value: "Pacific/Wake" },
{ label: "Wallis", value: "Pacific/Wallis" },
],
};
// Helper to get display label for a timezone value
export function getTimezoneLabel(value: string | undefined): string {
if (!value) return "UTC (default)";
return value;
}

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId");
const gitlabUrl = useMemo(() => {
const url = gitlabProviders?.find(
(provider) => provider.gitlabId === gitlabId,
)?.gitlabUrl;
const gitlabUrl = url?.replace(/\/$/, "");
return gitlabUrl || "https://gitlab.com";
}, [gitlabId, gitlabProviders]);
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

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;
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
/⚠|⚠️/i.test(lowerMessage)
) {
return LOG_STYLES.warning;
}

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

@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
type="password"
placeholder="******************"
autoComplete="one-time-code"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Input
type="password"
placeholder="******************"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>

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 (
<>
@@ -155,7 +190,9 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
@@ -251,13 +288,17 @@ export const ShowProjects = () => {
)
.some(Boolean);
const productionEnvironment = project?.environments.find(
(env) => env.isDefault,
);
return (
<div
key={project.projectId}
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (

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

@@ -0,0 +1,74 @@
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ShowInvoices } from "./show-invoices";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBillingInvoices = () => {
const router = useRouter();
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6">
<ShowInvoices />
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -4,11 +4,13 @@ import {
AlertTriangle,
CheckIcon,
CreditCard,
FileText,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.5;
return count * 3.5;
};
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBilling = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-col gap-4 w-full">
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}

View File

@@ -0,0 +1,137 @@
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
import type Stripe from "stripe";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "-";
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
const statusConfig: Record<
Stripe.Invoice.Status,
{ label: string; variant: "default" | "secondary" | "destructive" }
> = {
paid: { label: "Paid", variant: "default" },
open: { label: "Open", variant: "secondary" },
draft: { label: "Draft", variant: "secondary" },
void: { label: "Void", variant: "destructive" },
uncollectible: { label: "Uncollectible", variant: "destructive" },
};
if (!status) {
return <Badge variant="secondary">Unknown</Badge>;
}
const config = statusConfig[status] || {
label: status,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
export const ShowInvoices = () => {
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
return (
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[20vh]">
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
Loading invoices...
<Loader2 className="animate-spin" />
</span>
</div>
) : invoices && invoices.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">
{invoice.number || invoice.id.slice(0, 12)}
</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
{formatAmount(invoice.amountDue, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{invoice.hostedInvoiceUrl && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
invoice.hostedInvoiceUrl || "",
"_blank",
)
}
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
{invoice.invoicePdf && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(invoice.invoicePdf || "", "_blank")
}
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No invoices found</p>
<p className="text-sm text-muted-foreground">
Your invoices will appear here once you have a subscription
</p>
</div>
)}
</div>
);
};

View File

@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string().min(1, {
message: "Password is required",
}),
registryUrl: z.string(),
password: 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(),
isEditing: z.boolean().optional(),
});
type AddRegistry = z.infer<typeof AddRegistrySchema>;
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
const { mutateAsync, error, isError } = registryId
? api.registry.update.useMutation()
: api.registry.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: deployServers } = api.server.withSSHKey.useQuery();
const { data: buildServers } = api.server.buildServers.useQuery();
const servers = [...(deployServers || []), ...(buildServers || [])];
const {
mutateAsync: testRegistry,
isLoading,
error: testRegistryError,
isError: testRegistryIsError,
} = api.registry.testRegistry.useMutation();
const {
mutateAsync: testRegistryById,
isLoading: isLoadingById,
error: testRegistryByIdError,
isError: testRegistryByIdIsError,
} = api.registry.testRegistryById.useMutation();
const form = useForm<AddRegistry>({
defaultValues: {
username: "",
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
imagePrefix: "",
registryName: "",
serverId: "",
isEditing: !!registryId,
},
resolver: zodResolver(AddRegistrySchema),
resolver: zodResolver(
AddRegistrySchema.refine(
(data) => {
// When creating a new registry, password is required
if (
!data.isEditing &&
(!data.password || data.password.length === 0)
) {
return false;
}
return true;
},
{
message: "Password is required",
path: ["password"],
},
),
),
});
const password = form.watch("password");
@@ -99,6 +151,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) {
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: registry.registryUrl,
imagePrefix: registry.imagePrefix || "",
registryName: registry.registryName,
isEditing: true,
});
} else {
form.reset({
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: "",
imagePrefix: "",
serverId: "",
isEditing: false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
const onSubmit = async (data: AddRegistry) => {
await mutateAsync({
password: data.password,
const payload: any = {
registryName: data.registryName,
username: data.username,
registryUrl: data.registryUrl,
registryUrl: data.registryUrl || "",
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
registryId: registryId || "",
})
};
// Only include password if it's been provided (not empty)
// When editing, empty password means "keep the existing password"
if (data.password && data.password.length > 0) {
payload.password = data.password;
}
await mutateAsync(payload)
.then(async (_data) => {
await utils.registry.all.invalidate();
toast.success(registryId ? "Registry updated" : "Registry added");
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
Fill the next fields to add a external registry.
</DialogDescription>
</DialogHeader>
{(isError || testRegistryIsError) && (
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testRegistryError?.message || error?.message || ""}
{testRegistryError?.message ||
testRegistryByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
{registryId && (
<FormDescription>
Leave blank to keep existing password. Enter new
password to test or update it.
</FormDescription>
)}
<FormControl>
<Input
placeholder="Password"
placeholder={
registryId
? "Leave blank to keep existing"
: "Password"
}
autoComplete="one-time-code"
{...field}
type="password"
@@ -261,6 +338,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 +363,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
@@ -294,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
{deployServers && deployServers.length > 0 && (
<SelectGroup>
<SelectLabel>Deploy Servers</SelectLabel>
{deployServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
{buildServers && buildServers.length > 0 && (
<SelectGroup>
<SelectLabel>Build Servers</SelectLabel>
{buildServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
<Button
type="button"
variant={"secondary"}
isLoading={isLoading}
isLoading={isLoading || isLoadingById}
onClick={async () => {
// When editing with empty password, use the existing password from DB
if (registryId && (!password || password.length === 0)) {
await testRegistryById({
registryId: registryId || "",
...(serverId && { serverId }),
})
.then((data) => {
if (data) {
toast.success("Registry Tested Successfully");
} else {
toast.error("Registry Test Failed");
}
})
.catch(() => {
toast.error("Error testing the registry");
});
return;
}
// When creating, password is required
if (!registryId && (!password || password.length === 0)) {
form.setError("password", {
type: "manual",
message: "Password is required",
});
return;
}
// When creating or editing with new password, validate and test with provided credentials
const validationResult = AddRegistrySchema.safeParse({
username,
password,
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryName: "Dokploy Registry",
imagePrefix,
serverId,
isEditing: !!registryId,
});
if (!validationResult.success) {
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryUrl: registryUrl || "",
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,

View File

@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
.then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate();
if (destinationId) {
await utils.destination.one.invalidate({ destinationId });
}
setOpen(false);
})
.catch(() => {

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,
@@ -321,6 +369,28 @@ export const HandleNotifications = ({ notificationId }: Props) => {
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
volumeBackup: notification.volumeBackup,
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,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
@@ -337,6 +407,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
custom: customMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -345,6 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy,
dokployRestart,
databaseBackup,
volumeBackup,
dockerCleanup,
serverThreshold,
} = data;
@@ -355,6 +427,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 +442,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 +458,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 +473,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 +492,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 +508,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken || "",
topic: data.topic,
@@ -446,6 +524,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
@@ -453,6 +532,33 @@ 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,
volumeBackup: volumeBackup,
endpoint: data.endpoint,
headers: headersRecord,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
customId: notification?.customId || "",
});
}
if (promise) {
@@ -1043,7 +1149,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 +1325,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 +1427,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark
isLoadingLark ||
isLoadingCustom
}
variant="secondary"
type="button"
@@ -1269,6 +1482,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

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, User } from "lucide-react";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,6 +27,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
@@ -41,6 +42,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),
});
@@ -73,6 +75,7 @@ export const ProfileForm = () => {
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
const availableAvatars = useMemo(() => {
if (gravatarHash === null) return randomImages;
@@ -88,7 +91,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 +106,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 +132,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 +142,7 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: "",
name: values.name || "",
lastName: values.lastName || "",
});
} catch (error) {
toast.error("Error updating the profile");
@@ -180,9 +187,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>
@@ -256,16 +276,8 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
defaultValue={getAvatarType(field.value)}
value={getAvatarType(field.value)}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -280,7 +292,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>
@@ -352,6 +364,40 @@ export const ProfileForm = () => {
/>
</FormLabel>
</FormItem>
<FormItem key="color-avatar">
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
<FormControl>
<RadioGroupItem
value="color"
className="sr-only"
/>
</FormControl>
<div
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
style={{
backgroundColor: isSolidColorAvatar(
field.value,
)
? field.value
: undefined,
}}
onClick={() =>
colorInputRef.current?.click()
}
>
{!isSolidColorAvatar(field.value) && (
<Palette className="h-5 w-5 text-muted-foreground" />
)}
</div>
<input
ref={colorInputRef}
type="color"
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
value={field.value}
onChange={field.onChange}
/>
</FormLabel>
</FormItem>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">

View File

@@ -1,4 +1,6 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
asButton?: boolean;
}
export const ShowServerActions = ({ serverId }: Props) => {
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>

View File

@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
serverId: serverId,
})
.then(async () => {
toast.success("Cleaned all");
toast.success("Cleaning in progress... Please wait");
})
.catch(() => {
toast.error("Error cleaning all");

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

@@ -7,9 +7,12 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.user.get.useQuery(undefined, {
enabled: !serverId,
});
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
undefined,
{
enabled: !serverId,
},
);
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
{
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
const enabled = serverId
? server?.enableDockerCleanup
: data?.user.enableDockerCleanup;
: data?.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
try {
await mutateAsync({
enableDockerCleanup: checked,
serverId: serverId,
...(serverId && { serverId }),
} as {
enableDockerCleanup: boolean;
serverId?: string;
});
if (serverId) {
await refetchServer();

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
@@ -59,9 +59,10 @@ type Schema = z.infer<typeof Schema>;
interface Props {
serverId?: string;
asButton?: boolean;
}
export const HandleServers = ({ serverId }: Props) => {
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
@@ -137,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{serverId ? (
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Edit Server
</DropdownMenuItem>
) : (
)
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Create Server
</Button>
)}
</DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>

View File

@@ -80,7 +80,7 @@ const Schema = z.object({
type Schema = z.infer<typeof Schema>;
export const SetupMonitoring = ({ serverId }: Props) => {
const { data } = serverId
const { data: serverData } = serverId
? api.server.one.useQuery(
{
serverId: serverId || "",
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: api.user.getServerMetrics.useQuery();
: { data: null };
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const data = serverId ? serverData : webServerSettings;
const url = useUrl();

View File

@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -22,7 +22,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
asButton?: boolean;
}
export const SetupServer = ({ serverId }: Props) => {
export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -81,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server
</DropdownMenuItem>
</DialogTrigger>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>
<div className="flex flex-col gap-1.5">

View File

@@ -1,5 +1,16 @@
import { format } from "date-fns";
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
import {
Clock,
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
ServerIcon,
Terminal,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
@@ -18,20 +29,15 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
@@ -59,7 +65,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -114,240 +120,320 @@ export const ShowServers = () => {
<HandleServers />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>
<div className="flex flex-col gap-4">
See all servers
</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="text-left">Name</TableHead>
{isCloud && (
<TableHead className="text-center">
Status
</TableHead>
)}
<TableHead className="text-center">
Type
</TableHead>
<TableHead className="text-center">
IP Address
</TableHead>
<TableHead className="text-center">
Port
</TableHead>
<TableHead className="text-center">
Username
</TableHead>
<TableHead className="text-center">
SSH Key
</TableHead>
<TableHead className="text-center">
Created
</TableHead>
<TableHead className="text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer =
server.serverType === "build";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
{server.name}
</TableCell>
{isCloud && (
<TableHead className="text-center">
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer = server.serverType === "build";
return (
<Card
key={server.serverId}
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<ServerIcon className="size-5 text-muted-foreground" />
<CardTitle className="text-lg">
{server.name}
</CardTitle>
</div>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
More options
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Advanced
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap">
{isCloud && (
<>
{server.serverStatus === "active" ? (
<Badge variant="default">
{server.serverStatus}
</Badge>
) : (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-block">
<Badge
variant="destructive"
className="cursor-help"
>
{server.serverStatus}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<p className="text-sm">
This server is deactivated due
to lack of payment. Please pay
your invoice to reactivate it.
If you think this is an error,
please contact support.
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
<Badge
variant={
server.serverStatus === "active"
? "default"
: "destructive"
isBuildServer
? "secondary"
: "default"
}
>
{server.serverStatus}
{server.serverType}
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge
variant={
isBuildServer ? "secondary" : "default"
}
>
{server.serverType}
</div>
</TooltipProvider>
</CardHeader>
<CardContent className="space-y-3 flex-1 flex flex-col">
<div className="flex items-center gap-2 text-sm">
<Network className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
IP:
</span>
<Badge variant="outline">
{server.ipAddress}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
<TableCell className="text-center">
{server.port}
</TableCell>
<TableCell className="text-center">
{server.username}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground">
Port:
</span>
<span className="font-medium">
{server.port}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<User className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
User:
</span>
<span className="font-medium">
{server.username}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Key className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
SSH Key:
</span>
<span className="font-medium">
{server.sshKeyId ? "Yes" : "No"}
</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
</div>
<div className="flex items-center gap-2 text-sm pt-2 border-t">
<Clock className="size-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Created{" "}
{format(
new Date(server.createdAt),
"PPpp",
"PPp",
)}
</span>
</TableCell>
</div>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{isActive && (
<>
{server.sshKeyId && (
<TerminalModal
serverId={server.serverId}
>
<span>
{t(
"settings.common.enterTerminal",
)}
</span>
</TerminalModal>
)}
{/* Compact Actions */}
{isActive && (
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<div className="space-y-1">
<p className="font-semibold">
Setup Server
</p>
<p className="text-xs text-muted-foreground">
Configure and initialize your
server with Docker, Traefik, and
other essential services
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<HandleServers
serverId={server.serverId}
/>
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
<TooltipProvider>
{server.sshKeyId && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
/>
)}
</>
asButton={true}
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
<Tooltip>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
</Tooltip>
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
</Tooltip>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<div className="flex-1" />
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active services
associated with this
server, please delete
them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
{data && data?.length > 0 && (
<div>
<HandleServers />

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:
@@ -63,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.user.get.useQuery();
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -78,15 +82,15 @@ export const WebDomain = () => {
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
const host = data?.user?.host || "";
const host = data?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
domain: data?.host || "",
certificateType: data?.certificateType || "none",
letsEncryptEmail: data?.letsEncryptEmail || "",
https: data?.https || false,
});
}
}, [form, form.reset, data]);

View File

@@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data } = api.user.get.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
@@ -53,7 +54,7 @@ export const WebServer = () => {
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.user.serverIp}
Server IP: {webServerSettings?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

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

@@ -24,10 +24,16 @@ const getTerminalKey = () => {
interface Props {
children?: React.ReactNode;
serverId: string;
asButton?: boolean;
}
export const TerminalModal = ({ children, serverId }: Props) => {
export const TerminalModal = ({
children,
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";
const { data } = api.server.one.useQuery(
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
};
return (
<Dialog>
<DialogTrigger asChild>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}

View File

@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data } = api.user.get.useQuery();
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.user.update.useMutation();
api.settings.updateServerIp.useMutation();
const form = useForm<Schema>({
defaultValues: {
serverIp: data?.user.serverIp || "",
serverIp: data?.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
serverIp: data.user.serverIp || "",
serverIp: data.serverIp || "",
});
}
}, [form, form.reset, data]);
const utils = api.useUtils();
const setCurrentIp = () => {
if (!ip) return;
form.setValue("serverIp", ip);
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
await utils.user.get.invalidate();
await refetch();
setIsOpen(false);
})
.catch(() => {

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

@@ -1,3 +1,4 @@
import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { Fragment } from "react";
import {
@@ -5,18 +6,31 @@ import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
interface Props {
list: {
interface BreadcrumbEntry {
name: string;
href?: string;
dropdownItems?: {
name: string;
href?: string;
href: string;
}[];
}
interface Props {
list: BreadcrumbEntry[];
}
export const BreadcrumbSidebar = ({ list }: Props) => {
return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
@@ -29,13 +43,29 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
{list.map((item, index) => (
<Fragment key={item.name}>
<BreadcrumbItem className="block">
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
{item.href ? (
<Link href={item?.href}>{item?.name}</Link>
) : (
item?.name
)}
</BreadcrumbLink>
{item.dropdownItems && item.dropdownItems.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 hover:text-foreground transition-colors outline-none">
{item.name}
<ChevronDown className="h-4 w-4 opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{item.dropdownItems.map((subItem) => (
<DropdownMenuItem key={subItem.href} asChild>
<Link href={subItem.href}>{subItem.name}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
{item.href ? (
<Link href={item?.href}>{item?.name}</Link>
) : (
<BreadcrumbPage>{item?.name}</BreadcrumbPage>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{index + 1 < list.length && (
<BreadcrumbSeparator className="block" />

View File

@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
return (
<div className="flex w-full items-center space-x-2">
<Input ref={inputRef} type={"password"} {...props} />
<Input ref={inputRef} {...props} type="password" />
<Button
variant={"secondary"}
onClick={() => {

View File

@@ -1,6 +1,6 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { isSolidColorAvatar } from "@/lib/avatar-utils";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
src?: string | null;
}
>(({ className, src, ...props }, ref) => {
if (isSolidColorAvatar(src)) {
return (
<div
key={`solid-${src}`}
ref={ref}
className={cn("aspect-square h-full w-full rounded-full", className)}
style={{
backgroundColor: src,
}}
{...props}
/>
);
}
return (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
src={src ?? ""}
{...props}
/>
);
});
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<

View File

@@ -1,18 +1,75 @@
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react";
import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
errorMessage?: string;
enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, type, ...props }, ref) => {
(
{
className,
errorMessage,
type,
enablePasswordGenerator = false,
passwordGeneratorLength,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const isPassword = type === "password";
const shouldShowGenerator =
isPassword &&
enablePasswordGenerator !== false &&
!props.disabled &&
!props.readOnly;
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
// @ts-ignore
inputRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
const handleGeneratePassword = () => {
const nextValue =
typeof passwordGeneratorLength === "number" &&
passwordGeneratorLength > 0
? generateRandomPassword(Math.floor(passwordGeneratorLength))
: generateRandomPassword();
const input = inputRef.current;
if (!input) {
return;
}
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (valueSetter) {
valueSetter.call(input, nextValue);
} else {
input.value = nextValue;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
};
return (
<>
<div className="relative w-full">
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
isPassword && "pr-10", // Add padding for the eye icon
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
className,
)}
ref={ref}
ref={setRefs}
{...props}
/>
{isPassword && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{shouldShowGenerator && (
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
</button>
)}
</button>
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
{errorMessage && (

View File

@@ -0,0 +1,84 @@
import { MinusIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface UnitConverter {
toValue: (raw: string | undefined) => number;
fromValue: (value: number) => string;
formatDisplay: (value: number) => string;
}
export const createConverter = (
multiplier: number,
formatDisplay: (value: number) => string,
): UnitConverter => ({
toValue: (raw) => {
if (!raw) return 0;
const value = Number.parseInt(raw, 10);
return Number.isNaN(value) ? 0 : value / multiplier;
},
fromValue: (value) =>
value <= 0 ? "" : String(Math.round(value * multiplier)),
formatDisplay,
});
interface NumberInputWithStepsProps {
value: string | undefined;
onChange: (value: string) => void;
placeholder: string;
step: number;
converter: UnitConverter;
}
export const NumberInputWithSteps = ({
value,
onChange,
placeholder,
step,
converter,
}: NumberInputWithStepsProps) => {
const numericValue = converter.toValue(value);
const displayValue = converter.formatDisplay(numericValue);
const handleIncrement = () =>
onChange(converter.fromValue(numericValue + step));
const handleDecrement = () =>
onChange(converter.fromValue(Math.max(0, numericValue - step)));
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleDecrement}
disabled={numericValue <= 0}
>
<MinusIcon className="h-4 w-4" />
</Button>
<Input
placeholder={placeholder}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="text-center"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleIncrement}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{displayValue && (
<span className="text-xs text-muted-foreground text-center">
{displayValue}
</span>
)}
</div>
);
};

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';

View File

@@ -0,0 +1,114 @@
CREATE TABLE "webServerSettings" (
"id" text PRIMARY KEY NOT NULL,
"serverIp" text,
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
"https" boolean DEFAULT false NOT NULL,
"host" text,
"letsEncryptEmail" text,
"sshPrivateKey" text,
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
"logCleanupCron" text DEFAULT '0 0 * * *',
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now() NOT NULL
);
-- Migrate data from user table to webServerSettings
-- Get the owner user's data and insert into webServerSettings
INSERT INTO "webServerSettings" (
"id",
"serverIp",
"certificateType",
"https",
"host",
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"logCleanupCron",
"metricsConfig",
"cleanupCacheApplications",
"cleanupCacheOnPreviews",
"cleanupCacheOnCompose",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid()::text as "id",
u."serverIp",
COALESCE(u."certificateType", 'none') as "certificateType",
COALESCE(u."https", false) as "https",
u."host",
u."letsEncryptEmail",
u."sshPrivateKey",
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
COALESCE(
u."metricsConfig",
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
) as "metricsConfig",
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
NOW() as "created_at",
NOW() as "updated_at"
FROM "user" u
INNER JOIN "member" m ON u."id" = m."user_id"
WHERE m."role" = 'owner'
ORDER BY m."created_at" ASC
LIMIT 1;
-- If no owner found, create a default entry
INSERT INTO "webServerSettings" (
"id",
"serverIp",
"certificateType",
"https",
"host",
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"logCleanupCron",
"metricsConfig",
"cleanupCacheApplications",
"cleanupCacheOnPreviews",
"cleanupCacheOnCompose",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid()::text as "id",
NULL as "serverIp",
'none' as "certificateType",
false as "https",
NULL as "host",
NULL as "letsEncryptEmail",
NULL as "sshPrivateKey",
true as "enableDockerCleanup",
'0 0 * * *' as "logCleanupCron",
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
false as "cleanupCacheApplications",
false as "cleanupCacheOnPreviews",
false as "cleanupCacheOnCompose",
NOW() as "created_at",
NOW() as "updated_at"
WHERE NOT EXISTS (
SELECT 1 FROM "webServerSettings"
);
--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -876,6 +876,76 @@
"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
},
{
"idx": 133,
"version": "7",
"when": 1766301478005,
"tag": "0133_striped_the_order",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1767871040249,
"tag": "0134_strong_hercules",
"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

@@ -0,0 +1,30 @@
/**
* Checks if the given avatar value represents a solid color in hexadecimal format.
*
* @param value Avatar value to check.
*
* @return True if the avatar is a solid color, false otherwise.
*/
export function isSolidColorAvatar(value?: string | null) {
return (
(value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) ||
value?.startsWith("color:") ||
false
);
}
/**
* Gets the avatar type for form selection (RadioGroup value).
*
* @param value Avatar value.
*
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
*/
export function getAvatarType(value?: string | null) {
if (!value) return "";
if (value.startsWith("data:")) return "upload";
if (isSolidColorAvatar(value)) return "color";
return value;
}

View File

@@ -0,0 +1,38 @@
const DEFAULT_PASSWORD_LENGTH = 20;
const DEFAULT_PASSWORD_CHARSET =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export const generateRandomPassword = (
length: number = DEFAULT_PASSWORD_LENGTH,
charset: string = DEFAULT_PASSWORD_CHARSET,
) => {
const safeLength =
Number.isFinite(length) && length > 0
? Math.floor(length)
: DEFAULT_PASSWORD_LENGTH;
if (safeLength <= 0 || charset.length === 0) {
return "";
}
const cryptoApi =
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
if (!cryptoApi?.getRandomValues) {
let fallback = "";
for (let i = 0; i < safeLength; i += 1) {
fallback += charset[Math.floor(Math.random() * charset.length)];
}
return fallback;
}
const values = new Uint32Array(safeLength);
cryptoApi.getRandomValues(values);
let result = "";
for (const value of values) {
result += charset[value % charset.length];
}
return result;
};

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