Compare commits

...

193 Commits

Author SHA1 Message Date
Mauricio Siu
3e5a189177 Merge pull request #3455 from Dokploy/3454-subscribe-issue
chore: update dokploy version to v0.26.5 and modify Stripe session cr…
2026-01-15 09:23:45 -06:00
Mauricio Siu
2b9231dcd1 chore: update dokploy version to v0.26.5 and modify Stripe session creation logic to conditionally set customer or customer_email 2026-01-15 09:18:00 -06:00
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
Vlad Vladov
d465fb4da1 feat(resources): Add number component to have better UX control over Docker resources 2025-12-07 13:45:14 +02: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
144 changed files with 31870 additions and 1118 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

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

@@ -1,7 +1,7 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { parse, stringify } from "yaml";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
/**
* Regression tests for Traefik Host rule label format.

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: "",
@@ -67,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

@@ -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,21 +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",
firstName: "",
lastName: "",
certificateType: "none",
host: null,
serverIp: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
metricsConfig: {
containers: {
refreshRate: 20,
@@ -45,29 +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,
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
createdAt: null,
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -85,7 +70,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
...baseSettings,
https: true,
certificateType: "letsencrypt",
},
@@ -100,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");
@@ -110,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");
@@ -119,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: "",
@@ -49,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

@@ -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>
@@ -405,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,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: "* * * * *" },
@@ -60,30 +77,6 @@ export const commonCronExpressions = [
{ label: "Custom", value: "custom" },
];
export const commonTimezones = [
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
{
label: "America/Mexico_City (Central Mexico)",
value: "America/Mexico_City",
},
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
{
label: "Australia/Sydney (Australian Eastern Time)",
value: "Australia/Sydney",
},
];
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
@@ -512,25 +505,60 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
</Tooltip>
</TooltipProvider>
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="UTC (default)" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonTimezones.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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>

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

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

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

@@ -102,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}`,
);
}
};
@@ -123,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}`,
);
}
};
@@ -140,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) {
@@ -239,8 +246,8 @@ export const AdvancedEnvironmentSelector = ({
)}
</div>
</DropdownMenuItem>
{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"
@@ -252,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

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

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

@@ -369,6 +369,7 @@ 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") {
@@ -388,6 +389,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)
: [],
name: notification.name,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
@@ -522,6 +524,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
@@ -547,6 +550,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
endpoint: data.endpoint,
headers: headersRecord,
name: data.name,

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";
@@ -74,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;
@@ -274,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">
@@ -370,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

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

@@ -67,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();
@@ -82,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

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

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

View File

@@ -918,6 +918,34 @@
"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

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

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.26.0",
"version": "v0.26.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -13,7 +13,6 @@
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
@@ -110,7 +109,6 @@
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
@@ -118,7 +116,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.0.7",
"next": "^16.0.10",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
@@ -127,7 +125,6 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
"otpauth": "^9.4.0",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
@@ -141,7 +138,6 @@
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"rotating-file-stream": "3.2.3",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "1.15.0",
@@ -157,9 +153,11 @@
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},
"devDependencies": {
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",

View File

@@ -81,41 +81,34 @@ export default async function handler(
return;
}
if (!webhookImageName) {
res.status(301).json({
message: "Webhook Docker Image Name Not Found",
});
return;
}
// If webhook provides image information, validate it matches the configured image
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
if (webhookImageName) {
// Validate image name matches
if (webhookImageName !== applicationImageName) {
res.status(301).json({
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
});
return;
}
// Validate image name matches
if (webhookImageName !== applicationImageName) {
res.status(301).json({
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
});
return;
}
if (!applicationDockerTag) {
res.status(301).json({
message: "Application Docker Tag Not Found",
});
return;
}
if (!applicationDockerTag) {
res.status(301).json({
message: "Application Docker Tag Not Found",
});
return;
}
if (!webhookDockerTag) {
res.status(301).json({
message: "Webhook Docker Tag Not Found",
});
return;
}
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
if (webhookDockerTag) {
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
}
}
}
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
} else if (sourceType === "github") {
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
@@ -249,17 +242,19 @@ export default async function handler(
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
await deploy(jobData);
return true;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
} catch (error) {
res.status(400).json({ message: "Error deploying Application", error });
return;

View File

@@ -179,17 +179,19 @@ export default async function handler(
if (IS_CLOUD && composeResult.serverId) {
jobData.serverId = composeResult.serverId;
await deploy(jobData);
return true;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
} catch (error) {
res.status(400).json({ message: "Error deploying Compose", error });
return;

View File

@@ -128,7 +128,9 @@ export default async function handler(
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(
@@ -165,7 +167,9 @@ export default async function handler(
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
@@ -246,7 +250,9 @@ export default async function handler(
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(
@@ -291,7 +297,9 @@ export default async function handler(
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
@@ -491,7 +499,9 @@ export default async function handler(
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(

View File

@@ -279,6 +279,16 @@ const EnvironmentPage = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId, environmentId } = props;
const { data: auth } = api.user.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: projectId,
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
@@ -863,6 +873,7 @@ const EnvironmentPage = (
},
{
name: currentEnvironment.name,
dropdownItems: environmentDropdownItems,
},
]}
/>
@@ -898,7 +909,9 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
{(auth?.role === "owner" || auth?.canCreateServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateServices) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
@@ -1021,6 +1034,7 @@ const EnvironmentPage = (
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<>
<DialogAction

View File

@@ -91,6 +91,15 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
<UseKeyboardNav forPage="application" />
@@ -98,11 +107,11 @@ const Service = (
list={[
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment.project.name || "",
name: data?.environment?.project?.name || "",
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -183,7 +192,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={applicationId} type="application" />
)}
</div>

View File

@@ -80,6 +80,14 @@ const Service = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
@@ -92,7 +100,7 @@ const Service = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -174,7 +182,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" />
)}
</div>

View File

@@ -62,6 +62,15 @@ const Mariadb = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
<UseKeyboardNav forPage="mariadb" />
@@ -73,7 +82,7 @@ const Mariadb = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -147,7 +156,9 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mariadbId} type="mariadb" />
)}
</div>

View File

@@ -61,6 +61,14 @@ const Mongo = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
@@ -73,7 +81,7 @@ const Mongo = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -147,7 +155,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mongoId} type="mongo" />
)}
</div>

View File

@@ -60,6 +60,14 @@ const MySql = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
@@ -72,7 +80,7 @@ const MySql = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -148,7 +156,9 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mysqlId} type="mysql" />
)}
</div>

View File

@@ -60,6 +60,14 @@ const Postgresql = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
@@ -72,7 +80,7 @@ const Postgresql = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -146,7 +154,9 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={postgresId} type="postgres" />
)}
</div>

View File

@@ -60,6 +60,14 @@ const Redis = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return (
<div className="pb-10">
@@ -72,7 +80,7 @@ const Redis = (
},
{
name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
dropdownItems: environmentDropdownItems,
},
{
name: data?.name || "",
@@ -146,7 +154,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={redisId} type="redis" />
)}
</div>

View File

@@ -0,0 +1,63 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return <ShowBillingInvoices />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -1,8 +1,8 @@
import {
findUserById,
getWebServerSettings,
IS_CLOUD,
setupWebMonitoring,
updateUser,
updateWebServerSettings,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
@@ -11,7 +11,7 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
@@ -19,15 +19,8 @@ export const adminRouter = createTRPCRouter({
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.ownerId);
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
await updateWebServerSettings({
metricsConfig: {
server: {
type: "Dokploy",
@@ -52,8 +45,9 @@ export const adminRouter = createTRPCRouter({
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
await setupWebMonitoring();
const settings = await getWebServerSettings();
return settings;
} catch (error) {
throw error;
}

View File

@@ -68,6 +68,40 @@ export const aiRouter = createTRPCRouter({
{ headers: {} },
);
break;
case "perplexity":
// Perplexity doesn't have a /models endpoint, return hardcoded list
return [
{
id: "sonar-deep-research",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-reasoning-pro",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-reasoning",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-pro",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
] as Model[];
default:
if (!input.apiKey)
throw new TRPCError({

View File

@@ -336,7 +336,9 @@ export const applicationRouter = createTRPCRouter({
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
@@ -365,6 +367,7 @@ export const applicationRouter = createTRPCRouter({
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
createEnvFile: input.createEnvFile,
});
return true;
}),
@@ -700,7 +703,9 @@ export const applicationRouter = createTRPCRouter({
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
@@ -812,7 +817,9 @@ export const applicationRouter = createTRPCRouter({
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}

View File

@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
.mutation(async ({ input }) => {
const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
return true;
}),
listBackupFiles: protectedProcedure

View File

@@ -17,8 +17,8 @@ import {
findGitProviderById,
findProjectById,
findServerById,
findUserById,
getComposeContainer,
getWebServerSettings,
IS_CLOUD,
loadServices,
randomizeComposeFile,
@@ -417,7 +417,9 @@ export const composeRouter = createTRPCRouter({
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
@@ -428,7 +430,11 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Deployment queued" };
return {
success: true,
message: "Deployment queued",
composeId: compose.composeId,
};
}),
redeploy: protectedProcedure
.input(apiRedeployCompose)
@@ -453,7 +459,9 @@ export const composeRouter = createTRPCRouter({
};
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
@@ -464,7 +472,11 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Redeployment queued" };
return {
success: true,
message: "Redeployment queued",
composeId: compose.composeId,
};
}),
stop: protectedProcedure
.input(apiFindCompose)
@@ -565,8 +577,7 @@ export const composeRouter = createTRPCRouter({
const template = await fetchTemplateFiles(input.id, input.baseUrl);
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
const project = await findProjectById(environment.projectId);
@@ -575,6 +586,9 @@ export const composeRouter = createTRPCRouter({
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const projectName = slugify(`${project.name} ${input.id}`);
@@ -799,14 +813,16 @@ export const composeRouter = createTRPCRouter({
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);
const config = parse(templateData.config) as CompleteTemplate;
@@ -876,14 +892,16 @@ export const composeRouter = createTRPCRouter({
await removeDomainById(domain.domainId);
}
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);

View File

@@ -9,6 +9,7 @@ import {
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getWebServerSettings,
manageDomain,
removeDomain,
removeDomainById,
@@ -107,16 +108,13 @@ export const domainRouter = createTRPCRouter({
}),
canGenerateTraefikMeDomains: protectedProcedure
.input(z.object({ serverId: z.string() }))
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
.query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
return organization?.owner.serverIp;
const settings = await getWebServerSettings();
return settings?.serverIp || "";
}),
update: protectedProcedure

View File

@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
if (input.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
message:
"You cannot create a environment with the name 'production'",
});
}
// Allow users to create environments with any name, including "production"
const environment = await createEnvironment(input);
if (ctx.user.role === "member") {
@@ -206,6 +208,14 @@ export const environmentRouter = createTRPCRouter({
});
}
// Prevent deletion of the default environment
if (environment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot delete the default environment",
});
}
// Check environment deletion permission
await checkEnvironmentDeletionPermission(
ctx.user.id,
@@ -243,13 +253,7 @@ export const environmentRouter = createTRPCRouter({
try {
const { environmentId, ...updateData } = input;
if (updateData.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
});
}
// Allow users to rename environments to any name, including "production"
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
@@ -259,6 +263,14 @@ export const environmentRouter = createTRPCRouter({
);
}
const currentEnvironment = await findEnvironmentById(environmentId);
// Prevent renaming the default environment, but allow updating env and description
if (currentEnvironment.isDefault && updateData.name !== undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot rename the default environment",
});
}
if (
currentEnvironment.project.organizationId !==
ctx.session.activeOrganizationId

View File

@@ -87,7 +87,7 @@ export const mariadbRouter = createTRPCRouter({
type: "volume",
});
return true;
return newMariadb;
} catch (error) {
if (error instanceof TRPCError) {
throw error;

View File

@@ -87,7 +87,7 @@ export const mongoRouter = createTRPCRouter({
type: "volume",
});
return true;
return newMongo;
} catch (error) {
if (error instanceof TRPCError) {
throw error;

View File

@@ -89,7 +89,7 @@ export const mysqlRouter = createTRPCRouter({
type: "volume",
});
return true;
return newMysql;
} catch (error) {
if (error instanceof TRPCError) {
throw error;

View File

@@ -8,6 +8,7 @@ import {
createSlackNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
IS_CLOUD,
removeNotificationById,
sendCustomNotification,
@@ -66,7 +67,6 @@ import {
apiUpdateTelegram,
notifications,
server,
user,
} from "@/server/db/schema";
export const notificationRouter = createTRPCRouter({
@@ -364,21 +364,20 @@ export const notificationRouter = createTRPCRouter({
let organizationId = "";
let ServerName = "";
if (input.ServerType === "Dokploy") {
const result = await db
.select()
.from(user)
.where(
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.id) {
const settings = await getWebServerSettings();
if (
!settings?.metricsConfig?.server?.token ||
settings.metricsConfig.server.token !== input.Token
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token not found",
});
}
organizationId = result?.[0]?.id;
// For Dokploy server type, we don't have a specific organizationId
// This might need to be adjusted based on your business logic
organizationId = "";
ServerName = "Dokploy";
} else {
const result = await db

View File

@@ -91,7 +91,7 @@ export const postgresRouter = createTRPCRouter({
type: "volume",
});
return true;
return newPostgres;
} catch (error) {
if (error instanceof TRPCError) {
throw error;

View File

@@ -2,11 +2,15 @@ import {
findApplicationById,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { apiFindAllByApplication } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
}
return previewDeployment;
}),
redeploy: protectedProcedure
.input(
z.object({
previewDeploymentId: z.string(),
title: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this preview deployment",
});
}
const application = await findApplicationById(
previewDeployment.applicationId,
);
const jobData: DeploymentJob = {
applicationId: previewDeployment.applicationId,
titleLog: input.title || "Rebuild Preview Deployment",
descriptionLog: input.description || "",
type: "redeploy",
applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
return true;
}),
});

View File

@@ -15,6 +15,7 @@ import {
apiFindOneRegistry,
apiRemoveRegistry,
apiTestRegistry,
apiTestRegistryById,
apiUpdateRegistry,
registry,
} from "@/server/db/schema";
@@ -109,6 +110,67 @@ export const registryRouter = createTRPCRouter({
});
}
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error testing the registry",
cause: error,
});
}
}),
testRegistryById: protectedProcedure
.input(apiTestRegistryById)
.mutation(async ({ input, ctx }) => {
try {
// Get the full registry with password from database
const registryData = await db.query.registry.findFirst({
where: eq(registry.registryId, input.registryId ?? ""),
});
if (!registryData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
if (registryData.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to test this registry",
});
}
const args = [
"login",
registryData.registryUrl,
"--username",
registryData.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Select a server to test the registry",
});
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
`echo ${registryData.password} | docker ${args.join(" ")}`,
);
} else {
await execFileAsync("docker", args, {
input: Buffer.from(registryData.password).toString(),
});
}
return true;
} catch (error) {
throw new TRPCError({

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