Compare commits

...

377 Commits

Author SHA1 Message Date
detail-app[bot] 65449e0a8c fix(security): write Traefik basic-auth middleware after DB commit on update 2026-09-04 02:52:38 +00:00
Narciso E. Núñez Arias 1572008cdf Merge pull request #5272 from bogdaaamn/bogdaaamn-fix-server-card-title-overflow
fix: Fix server card title overflow for responsiveness
2026-09-03 15:50:18 -04:00
autofix-ci[bot] 12ea7a8920 [autofix.ci] apply automated fixes 2026-09-03 17:16:32 +00:00
Narciso E. Núñez Arias 782bb4e249 Merge pull request #5287 from imrja8/fix/sidebar-truncation
fix(ui): truncate organization name properly in sidebar without clipp…
2026-09-03 11:05:36 -04:00
Narciso E. Núñez Arias f4e1154d31 Merge pull request #4765 from imrja8/feat/organization-logo-drag-drop
feat: add drag-and-drop logo upload for organizations
2026-09-03 10:49:41 -04:00
Narciso E. Núñez Arias bf547e2fd0 Merge pull request #4769 from imrja8/fix/whitelabeling-fouc
fix: eliminate whitelabeling FOUC (Replaces #4753)
2026-09-03 10:49:11 -04:00
autofix-ci[bot] d1fda6368c [autofix.ci] apply automated fixes 2026-09-03 04:16:04 +00:00
Yash Kumar d389b3c8d0 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-03 09:33:29 +05:30
Yash Kumar 0889d763ef Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-03 09:33:04 +05:30
Yash Kumar bbca400a6d fix(ui): truncate organization name properly in sidebar without clipping descenders 2026-09-03 09:33:02 +05:30
Narciso E. Núñez Arias 03cdd00fa8 Merge pull request #5282 from Dokploy/dependabot/npm_and_yarn/packages/server/npm_and_yarn-343aa972c6
chore(deps): bump nanoid from 3.3.11 to 3.3.18 in /packages/server in the npm_and_yarn group across 1 directory
2026-09-02 17:50:12 -04:00
Narciso ad00669f0f fix: sync pnpm-lock.yaml with nanoid 3.3.18
The nanoid specifier in packages/server/package.json was bumped to
3.3.18 but the lockfile entry was not updated, causing
pnpm install --frozen-lockfile to fail in CI.
2026-09-02 17:46:10 -04:00
dependabot[bot] f58ff1062e chore(deps): bump nanoid
Bumps the npm_and_yarn group with 1 update in the /packages/server directory: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 3.3.11 to 3.3.18
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.11...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 21:35:43 +00:00
Yash Kumar c180f7502c refactor: extract resizeImage to shared utility 2026-09-02 20:57:30 +05:30
Yash Kumar 4877c4ffa3 fix: address PR feedback (SVG helper & AddOrganization crash) 2026-09-02 20:49:14 +05:30
Yash Kumar dfa9c17d7d refactor: extract createServerSideHelpers into a reusable function 2026-09-02 19:03:24 +05:30
Yash Kumar e25e241cf5 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 17:25:46 +05:30
Yash Kumar f617561d0b Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 17:25:28 +05:30
Bogdan Covrig f904a7bd55 fix: Fix server card title overflow for responsiveness 2026-09-02 13:48:05 +02:00
Narciso E. Núñez Arias 261ebb2317 Merge pull request #5271 from imrja8/fix/ci-docker-tests 2026-09-02 07:41:19 -04:00
autofix-ci[bot] c901260d81 [autofix.ci] apply automated fixes 2026-09-02 11:35:17 +00:00
Yash Kumar 8c9e473b4e test: fix CI failures and improve local test robustness 2026-09-02 16:59:57 +05:30
Yash Kumar e2c9bd2833 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 14:24:08 +05:30
Yash Kumar 8b38acdebd Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 14:24:01 +05:30
Mauricio Siu 344febb4c1 bump version to v0.30.5 2026-09-02 00:47:29 -06:00
Mauricio Siu fff5612c2e Merge pull request #4195 from lamualfa/feat/compose-deploy-fresh-volumes
feat: add Deploy with Fresh Volumes for Docker Compose
2026-09-02 00:45:06 -06:00
autofix-ci[bot] c5f12927b5 [autofix.ci] apply automated fixes 2026-09-02 06:44:56 +00:00
Mauricio Siu 56eaa93b91 Merge branch 'canary' into feat/compose-deploy-fresh-volumes 2026-09-02 00:37:24 -06:00
Mauricio Siu e67d9efb38 Merge pull request #5231 from drago1520/fix/docker-context-path-default
fix/docker-context-path-default: Placeholder with name attribute "doc…
2026-09-02 00:29:37 -06:00
Mauricio Siu c32c8d01a9 Merge pull request #5264 from Dokploy/feat/cloud-onboarding-plan-gate
Cloud onboarding wizard, billing trial card, and post-checkout server setup
2026-09-02 00:09:01 -06:00
autofix-ci[bot] 0dcfcc19da [autofix.ci] apply automated fixes 2026-09-02 01:12:31 +00:00
Mauricio Siu 2e2e0c8c29 feat: cloud onboarding wizard, billing trial card, and post-checkout server setup
- Onboarding wizard (Welcome -> Plan -> Project -> Server -> Deploy ->
  Complete), shown once to an org owner with zero projects and no active
  plan/trial; skippable per step or entirely
- Billing page shows the org's current plan, and a no-card 14-day trial
  card when eligible
- Post-checkout "Welcome to Dokploy Cloud" modal simplified to reuse the
  onboarding wizard's own project/server/deploy steps behind a modal
  instead of its previous standalone 6-step flow, using the app's regular
  typography instead of the wizard's display serif
- Onboarding wizard validates a persisted project still exists before
  resuming a stale session, and the dashboard layout no longer gets stuck
  redirecting to /dashboard/home once the local onboarding-active flag
  goes stale mid-session
- onboardingCompletedAt column on user, with a backfill so existing users
  aren't shown the wizard
- pnpm reset-onboarding dev script to reset a test account's onboarding
  state end to end
2026-09-01 19:11:39 -06:00
Mauricio Siu a424f64370 chore(api): add local Inngest dev server script 2026-09-01 19:11:27 -06:00
autofix-ci[bot] 3893150f5c [autofix.ci] apply automated fixes 2026-09-01 20:57:36 +00:00
Yash Kumar 8806d4ef80 fix: resolve SVG unicode base64 encoding error and image upload race conditions 2026-09-02 02:27:14 +05:30
autofix-ci[bot] 6d51c100ae [autofix.ci] apply automated fixes 2026-09-02 01:55:05 +05:30
Yash Kumar 13bb1ac5d1 fix: account for all style end-tag variants like </style/> 2026-09-02 01:49:35 +05:30
Yash Kumar 5606a2bd1e fix: account for whitespace in style tag regex to prevent XSS 2026-09-02 01:44:46 +05:30
autofix-ci[bot] 58f3714ddc [autofix.ci] apply automated fixes 2026-09-01 20:01:38 +00:00
Yash Kumar 6282b6ef39 fix: resolve security vulnerabilities in whitelabeling
- Sanitize customCss in _document.tsx to prevent XSS breakouts by stripping </style> tags.
- Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching.
2026-09-02 01:31:12 +05:30
Yash Kumar d56ef890ae Cleanup leftover unused code in side.tsx 2026-09-01 21:31:36 +05:30
Yash Kumar b75785dc6a Merge canary and resolve conflicts 2026-09-01 21:29:49 +05:30
Yash Kumar c0f07f1427 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-01 18:52:38 +05:30
Yash Kumar da3c0b1326 Merge remote-tracking branch 'origin/main' into fix/whitelabeling-fouc
# Conflicts:
#	apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
#	apps/dokploy/pages/_app.tsx
2026-09-01 18:49:17 +05:30
Mauricio Siu c225188ed4 Merge pull request #5253 from Dokploy/fix/git-provider-secret-leak
fix: stop leaking git provider secrets to non-owner org members
2026-09-01 03:25:40 -06:00
Mauricio Siu 6239073e2a fix: strip git provider secrets from compose.one response
findComposeById embedded the full github/gitlab/bitbucket/gitea
relations (client secrets, OAuth tokens, private keys, app passwords)
and compose.one only used canEditDeployGitSource to set a
hasGitProviderAccess flag, never to hide the fields — so any member
with read access to a compose service got the connected git
provider's raw credentials, regardless of their access to that
provider itself.

Exclude the same secret columns findApplicationById already excludes.
Deploys are unaffected: the actual clone step always re-fetches the
provider fresh by id (findGithubById/findGitlabById/...), it never
reads secrets off the embedded relation.
2026-09-01 03:24:43 -06:00
Mauricio Siu a42614004f fix: don't leak git provider secrets to non-owner org members
gitlab.one, github.one, gitea.one and bitbucket.one returned the full
DB record (OAuth access/refresh tokens, client secrets, private keys,
webhook secrets, app passwords) to any org member who merely had
access to *use* a shared provider (sharedWithOrganization: true),
not just its owner or an org owner/admin.

Add canViewGitProviderSecrets() and null out the secret fields in
each .one response when the caller isn't the provider owner or an
org owner/admin.
2026-09-01 03:22:52 -06:00
Mauricio Siu e1a432d5c8 Merge pull request #5251 from Dokploy/feat/delete-server-services
feat: list associated services in delete server modal
2026-09-01 03:12:42 -06:00
Mauricio Siu 02760212f7 feat: list associated services in delete server modal
Show the services attached to a remote server directly in its delete
confirmation modal, with a link to each service and a per-service
delete action, instead of only showing a generic 'has active
services' blocker.
2026-09-01 03:11:12 -06:00
Mauricio Siu 0478f34be5 Merge pull request #5250 from Dokploy/feat/vault-bulk-import
feat: bulk import secrets from vault providers
2026-09-01 03:07:12 -06:00
Mauricio Siu 8b5c14ca74 test: fix stale traefik remote-write test expectation
writeTraefikConfigRemote moved from an execAsyncRemote echo command to
writeFileRemote (SFTP) a while back; the test still mocked the old
execAsyncRemote path and asserted on an echo command that no longer runs.
2026-09-01 03:05:38 -06:00
Mauricio Siu 50acb791cd feat: bulk import secrets from vault providers
Adds a modal to select and import multiple secrets from an assigned
vault provider at once, instead of typing each ${{vault.x.y}} reference
by hand. Existing keys are skipped by default and can be overridden per
row; also adds an "Access all" shortcut to the vault provider assignment
picker.
2026-09-01 03:00:09 -06:00
Mauricio Siu 3a6e18be79 Merge pull request #5228 from Dokploy/fix/hubspot-chat-multi-subscription-plan
fix: detect billing plan across all active Stripe subscriptions
2026-09-01 02:48:58 -06:00
Mauricio Siu 23b15801d5 bump version to v0.30.4 in package.json 2026-09-01 02:44:09 -06:00
Mauricio Siu 78fe5e3a33 Merge pull request #5150 from logical-tech/feat/dns-records-management
feat(dns): rework provider management and support all record types
2026-09-01 02:42:42 -06:00
Mauricio Siu 8cd2ba80f2 Merge branch 'canary' into feat/dns-records-management 2026-09-01 02:41:18 -06:00
Mauricio Siu 8639605b03 Merge pull request #5165 from outeiroDev/feat/porkbun-dns-provider
feat: add Porkbun DNS provider support
2026-09-01 02:40:33 -06:00
Mauricio Siu 1443500b9d Add new journal entry for wooden_nextwave version 7 2026-09-01 02:37:50 -06:00
Mauricio Siu 9c444ebca3 Merge branch 'canary' into feat/porkbun-dns-provider 2026-09-01 02:37:36 -06:00
Mauricio Siu e7eb20d92f Remove unused journal entry for porkbun DNS provider from _journal.json 2026-09-01 02:37:31 -06:00
Mauricio Siu f1a4f4317a refactor: remove DnsPageTransition component and update loading indicators in DNS-related components 2026-09-01 02:36:41 -06:00
Mauricio Siu 1ab4a8a70a Merge branch 'canary' into feat/dns-records-management 2026-09-01 02:28:22 -06:00
Mauricio Siu 53a6e926d6 Merge pull request #5123 from barrynorman/feat/vault-phase-provider
feat(vault): add Phase.dev secrets provider
2026-09-01 02:23:47 -06:00
autofix-ci[bot] 43178b6442 [autofix.ci] apply automated fixes 2026-09-01 08:23:38 +00:00
Mauricio Siu a194e1e27b Add entry for volatile piledriver in journal with version 7 2026-09-01 01:58:57 -06:00
Mauricio Siu 50479f20ff Merge branch 'canary' into feat/vault-phase-provider 2026-09-01 01:58:35 -06:00
Mauricio Siu 4453c55086 Remove unused journal entry for phase vault provider from _journal.json 2026-09-01 01:58:29 -06:00
Mauricio Siu 3c95912001 Merge pull request #5247 from Dokploy/fix/db-deploy-swarm-convergence-check
fix: verify swarm task convergence before marking db deploys done
2026-09-01 01:57:33 -06:00
Mauricio Siu 5624dff5c1 fix: verify swarm task convergence before marking db deploys done 2026-09-01 01:56:58 -06:00
Mauricio Siu d7ea9cf9bc Merge pull request #5246 from Dokploy/fix/traefik-remote-config-sftp-write
fix: write remote traefik config via SFTP instead of exec+base64
2026-09-01 01:54:21 -06:00
Mauricio Siu 68a05551d0 fix: write remote traefik config via SFTP instead of exec+base64
Embedding the full YAML (base64-encoded) into a single SSH exec command
silently fails once the payload passes the SSH transport's packet size
ceiling (~90-100KB), truncating the dynamic config with no error
surfaced anywhere. Switch to SFTP, which has no such limit.
2026-09-01 01:53:45 -06:00
autofix-ci[bot] 57c221de6f [autofix.ci] apply automated fixes 2026-09-01 07:49:01 +00:00
Mauricio Siu 48e0b747b6 Merge pull request #5245 from Dokploy/fix/schedule-cloud-update-orphan-job
fix: orphaned BullMQ job when updating an enabled schedule in cloud
2026-09-01 01:38:12 -06:00
Mauricio Siu 4e38925119 fix: pass timezone when removing repeatable schedule job
removeJob() for type 'schedule' computed the BullMQ repeatable key
using only the cron pattern, without the timezone. scheduleJob()
always registers the repeatable job with tz set (job.timezone || UTC),
so BullMQ's repeat key (name:jobId:endDate:tz:pattern) never matched
on removal — removeRepeatable silently no-op'd and the job kept
firing forever, surviving disable, delete, and reboots.

Forward timezone through removeJob (queue.ts), the /update-backup
lookup (index.ts), and the disable/delete call sites in the
schedule router.
2026-09-01 01:31:59 -06:00
Mauricio Siu 91bcf88df9 fix: use updateJob to replace repeatable schedule job on update in cloud
Fixes #5233. Updating an enabled schedule (cron/timezone change) called
schedule() (create-backup), which only adds a new repeatable job and
never removes the one registered under the old cron pattern, leaving
an orphaned BullMQ job running in Redis indefinitely. Switched to
updateJob() (update-backup), which looks up the currently registered
repeatable pattern, removes it, then re-adds — same pattern already
used in backup.ts for backups.
2026-09-01 01:26:15 -06:00
Mauricio Siu a63157cd51 Merge pull request #5244 from Dokploy/fix/notification-server-threshold-teams
fix: persist serverThreshold for gotify/ntfy and guard teams dispatch
2026-09-01 01:13:22 -06:00
Mauricio Siu 47308070a8 fix: persist serverThreshold for gotify/ntfy and guard teams dispatch 2026-09-01 01:11:31 -06:00
Mauricio Siu a1c57011a4 Merge pull request #5243 from Dokploy/fix/compose-project-directory-build-context
fix: only pin --project-directory when compose has mounts
2026-09-01 00:50:42 -06:00
Mauricio Siu ec8e645758 fix: only pin --project-directory when compose has mounts
--project-directory was added unconditionally in 87b914996 to fix
relative bind mount resolution for git-based compose deploys with a
nested composePath (#5181). It also moves where build.context and the
generated .env resolve, breaking any compose file in a subdirectory
that has context: . alongside its Dockerfile (#5230), or interpolates
env vars (#5242).

Only pin --project-directory when the compose actually has mounts
configured; otherwise build.context and .env resolve against the
compose file's own directory like plain docker compose. Also pass
--env-file explicitly pointing at the generated .env next to the
compose file, so it's found even when --project-directory is pinned
(credit: tonnenpinguin, PR #5235).

Fixes #5230
Fixes #5242
2026-09-01 00:50:05 -06:00
Narciso E. Núñez Arias e8f4a1715c Merge pull request #5014 from Shivam8584/fix/ui-polish-bugs
fix: honour metaName for page titles and show a loading state in requests
2026-08-31 16:48:32 -04:00
Narciso 880bb55272 fix failing test 2026-08-31 16:42:26 -04:00
Narciso a36739eb07 Merge remote-tracking branch 'origin/canary' into fix/ui-polish-bugs 2026-08-31 16:20:48 -04:00
Dokploy Bot 1ff9294009 chore: sync hotfix from main into canary [skip ci] 2026-08-31 19:37:35 +00:00
Narciso E. Núñez Arias 142082fe89 Merge pull request #5237 from shuv-o/feat/reset-password-by-email
feat: allow reset-password script to reset a specific user by email
(cherry picked from commit 5345c00ff3)

[skip ci]
2026-08-31 19:37:33 +00:00
Narciso E. Núñez Arias 5345c00ff3 Merge pull request #5237 from shuv-o/feat/reset-password-by-email
feat: allow reset-password script to reset a specific user by email
2026-08-31 15:37:20 -04:00
Narciso E. Núñez Arias d181384833 Merge pull request #5238 from rakeshbhugra/fix/deployment-commit-hash-overflow-mobile
fix: prevent deployment commit hash overflow on mobile
2026-08-31 14:40:07 -04:00
autofix-ci[bot] 1f5f5576f1 [autofix.ci] apply automated fixes 2026-08-31 18:33:29 +00:00
Rakesh Bhugra d6ec4bd3d1 fix: prevent deployment commit hash overflow on mobile 2026-08-31 23:49:36 +05:30
Shivam Gupta 7e9b77e93f fix: surface the error state instead of "No results." when the log query fails
The loading branch added in the previous commit split the fallback cell
two ways: spinner while in flight, "No results." otherwise. That second
branch also catches the failure case -- when readStatsLogs errors,
statsLogs stays undefined and isLoading goes false, so a failed request
renders as a successful empty response.

The query's isError/error were not destructured. The cell now branches
three ways and reports the error through AlertBlock, matching how
ShowTraefikSystem surfaces a failed readDirectories query.
2026-08-31 22:58:36 +05:30
Shuvo 8db39a8d15 fix: normalize email argument before lookup in reset-password
Trims and lowercases the CLI email arg to match the normalization
used elsewhere for user emails, so a differently-cased or
whitespace-padded email no longer falsely reports "User not found".
2026-08-31 23:06:04 +06:00
autofix-ci[bot] 714bf03244 [autofix.ci] apply automated fixes 2026-08-31 23:06:04 +06:00
Shuvo dada3049a8 feat: allow reset-password script to target a specific user by email
Previously reset-password only reset the owner account's password.
Passing an email as an argument now resets that specific user's
password instead, while omitting it keeps the existing owner-reset
behavior.

Also scopes the update to the credential (password-based) account
row via providerId, and fixes the success check to verify a row was
actually updated instead of always reporting success.
2026-08-31 23:06:04 +06:00
Shuvo 0971e1952a Merge branch 'Dokploy:canary' into canary 2026-08-31 22:22:09 +06:00
Narciso E. Núñez Arias e0a9b83a39 Merge pull request #4180 from TuroYT/fix/responsive-application-logs 2026-08-31 10:41:09 -04:00
Romain PINSOLLE ccfc44815e Merge branch 'canary' into fix/responsive-application-logs 2026-08-31 14:00:24 +02:00
drago1520 f1e2467bb9 fix/docker-context-path-default: Placeholder with name attribute "dockerContextPath" lied that the default path is ".", while being the Dockerfile directory. 2026-08-31 09:39:00 +03:00
Mauricio Siu 3f6dfbaaa7 fix: only sum currentPriceAmount from the identified plan subscription
Summing across all active subscriptions could mix amounts with
different billing intervals (isAnnualCurrent only reflects the
matched plan subscription), producing an inconsistent total.
2026-08-30 18:13:33 -06:00
Mauricio Siu e79239a100 fix: detect billing plan across all active Stripe subscriptions
getCurrentPlanForUser and getProducts only inspected subscriptions.data[0],
so a customer with more than one active Stripe subscription (e.g. Startup
plus a separately purchased additional server) could have their plan
resolved from the wrong subscription, resulting in currentPlan !== "startup"
and the HubSpot chat bubble not rendering.
2026-08-30 18:08:35 -06:00
Mauricio Siu 9e3d6fdf2c Merge pull request #5227 from Dokploy/canary
🚀 Release v0.30.3
2026-08-30 17:44:40 -06:00
Mauricio Siu cabfe2e81e Bump version from v0.30.2 to v0.30.3 2026-08-30 17:36:51 -06:00
Mauricio Siu b0ab9c77fb Merge pull request #5226 from Dokploy/feat/openpanel-cloud-analytics
feat: add OpenPanel analytics to cloud version
2026-08-30 17:36:26 -06:00
Mauricio Siu 2f7f50aec5 feat: add OpenPanel analytics to cloud version 2026-08-30 17:31:45 -06:00
Mauricio Siu 532de2c59e Merge pull request #5208 from lmachineone/codex/watch-path-negation
fix: apply watch path negations as a pattern set
2026-08-28 17:18:21 -06:00
Mauricio Siu 542eced075 Merge pull request #4889 from automaze-me/fix/security-audit-json-parse
fix(server): security audit JSON parse error on duplicate sshd_config directives
2026-08-28 16:57:58 -06:00
Mauricio Siu 4b2de31b1c Merge pull request #5214 from Dokploy/fix/issue-4658-domain-validation-multi-ip
fix: domain validation false negative on servers with multiple IPs
2026-08-28 16:50:48 -06:00
Mauricio Siu 4b7a6295fc fix: verify server ownership before running IP detection
validateDomain accepted an arbitrary serverId and used it to look up
the server and SSH into it, without checking it belonged to the
caller's active organization. A member with domain:read could probe
other orgs' servers and get their SSH/detected IPs back in the
response.

Addresses greptile review on #5214
2026-08-28 16:49:06 -06:00
Mauricio Siu a4e29172f4 Merge pull request #5215 from Dokploy/fix/traefik-config-test-typecheck
fix: typecheck errors in traefik config test
2026-08-28 16:47:45 -06:00
Mauricio Siu 22450abc4b fix: satisfy HttpRouter type in traefik config test
Test routers were missing the required "service" field, and
mock.calls[0] destructuring failed under noUncheckedIndexedAccess.
2026-08-28 16:47:09 -06:00
Mauricio Siu 976161647f fix: domain validation false negative on servers with multiple IPs
Validate against the server's SSH IP plus its detected public egress
IP, instead of only the stored SSH address.

Fixes #4658
2026-08-28 16:43:23 -06:00
Mauricio Siu bfc180dfef Merge pull request #5048 from Dokploy/fix/openapi-body-size-limits
fix(api): configurable body size limits for OpenAPI catch-all route
2026-08-28 16:35:34 -06:00
Mauricio Siu 628b14d07a Merge pull request #5213 from Dokploy/fix/issue-5133-stack-monitoring-remote-node
fix: report remote-node containers correctly in stack monitoring
2026-08-28 16:19:47 -06:00
Mauricio Siu 0efe7feaf5 fix: report remote-node containers correctly in stack monitoring
Free monitoring resolved containers via a local Dockerode listContainers()
call, which only sees containers scheduled on the host running Dokploy.
For Stack services with tasks on other Swarm nodes, this always fell into
the "Container not running" branch even when the task was healthy
elsewhere, since local docker.listContainers() can't see remote-node
containers.

Now falls back to `docker service ps` (Swarm-aggregated, works from any
manager regardless of task placement) to tell a genuinely stopped
container apart from one running on another node, and reports that
distinction in the WS close reason instead of the misleading message.

Also fixes a client-side race where the stats websocket connected with
an empty appName on first mount (before the container selector settled),
and surfaces the close reason as a toast instead of only logging it.
2026-08-28 16:19:10 -06:00
Mauricio Siu 474081a264 Merge pull request #5212 from Dokploy/fix/issue-5172-terminal-ws-close
fix: close terminal websocket when local container shell exits
2026-08-28 15:51:34 -06:00
Mauricio Siu 1053743a1c fix: close terminal websocket when local container shell exits 2026-08-28 15:50:53 -06:00
Mauricio Siu 355b856185 Merge pull request #5211 from Dokploy/fix/issue-5063-user-remove-double-submit
fix: disable delete/unlink user button while mutation is pending
2026-08-28 15:48:41 -06:00
autofix-ci[bot] a1cc2778f3 [autofix.ci] apply automated fixes 2026-08-28 21:48:23 +00:00
Mauricio Siu fee0071223 fix: disable delete/unlink user button while mutation is pending
Prevents double-submit when clicking Confirm on the Delete/Unlink User
dialog before the request resolves (issue #5063).
2026-08-28 15:47:46 -06:00
Mauricio Siu 3334edef73 Merge pull request #5210 from Dokploy/fix/issue-5134-stale-native-log-container-id
fix: keep native log container id in sync when Swarm/compose replaces it
2026-08-28 15:38:36 -06:00
Mauricio Siu cf0e8d236c fix: keep native log container id in sync when Swarm/compose replaces it
Closes #5134. The container list queries in the Logs tab only fetched
once on mount, so if the underlying container was replaced (restart,
reschedule, redeploy) while the page stayed open, the native log
WebSocket kept streaming from the stale id and either froze or failed
with 'No such container'.

Poll the container list every 5s and reconcile the selected id against
it, matching the pattern already used for automatic selection.
2026-08-28 15:38:01 -06:00
autofix-ci[bot] 9d9b41c1e5 [autofix.ci] apply automated fixes 2026-08-28 17:47:28 +00:00
Lucas Manchine bdf33750a3 Revert "[autofix.ci] apply automated fixes"
This reverts commit a66684a784.
2026-08-28 14:46:36 -03:00
autofix-ci[bot] a66684a784 [autofix.ci] apply automated fixes 2026-08-28 17:43:56 +00:00
Lucas Manchine abc2439ec4 fix: apply watch path negations as a pattern set 2026-08-28 14:43:18 -03:00
Mauricio Siu 5b6a8bd1ef Merge pull request #5142 from Dokploy/feat/add-terminal-permission
feat(permissions): add server.terminal to decouple server access from root SSH
2026-08-28 11:20:29 -06:00
Mauricio Siu d26f70642d Add journal entry for granting terminal permission to read roles 2026-08-28 11:20:13 -06:00
Mauricio Siu e2d2dbb00a Merge branch 'canary' into feat/add-terminal-permission 2026-08-28 11:19:02 -06:00
Mauricio Siu e4012d857d fix: enhance error handling and directory management in updateFileMount function 2026-08-28 11:18:53 -06:00
Mauricio Siu ab62080e9d Merge pull request #5203 from Dokploy/fix/file-mount-nested-path-update-5152
fix: File Mount update fails silently on nested paths
2026-08-27 10:49:56 -06:00
Mauricio Siu 3a8fad57e6 fix: mkdir parent dir before writing file mount updates
Fixes #5152. updateFileMount wrote directly to the target path without
ensuring its parent directory existed, and silently swallowed errors
instead of surfacing them. If the path already existed as a directory
(e.g. Docker pre-creating a bind mount target), the write silently
failed and left an empty directory instead of the file.
2026-08-27 10:49:15 -06:00
Mauricio Siu 203cddbeef Merge pull request #5202 from Dokploy/fix/traefik-empty-dynamic-config-5189
fix: don't write invalid empty traefik config for apps without domains
2026-08-27 09:37:56 -06:00
Mauricio Siu 032e804114 fix: remove empty routers/services traefik config instead of writing invalid yaml
Attaching Basic Auth or a Redirect to an application with no domains
wrote an empty routers/services block to the app's dynamic config
file. Traefik's file provider rejects that as invalid and aborts its
watcher, blocking config updates for every other application.

Fixes #5189
2026-08-27 09:37:14 -06:00
Mauricio Siu ca3da4dc5d Merge pull request #5201 from Dokploy/fix/compose-git-mounts-5181
fix: resolve relative bind mounts against code dir for git-based compose deploys
2026-08-27 09:15:38 -06:00
Mauricio Siu 87b914996f fix: resolve relative bind mounts against code dir for git-based compose deploys
Compose deployments using a git provider (GitHub/GitLab/Gitea/Bitbucket/
custom Git) clone the repo verbatim, so composePath can point into a
subdirectory (e.g. deploy/docker-compose.yml). Docker Compose resolves
relative bind mounts (../files) against the compose file's own directory,
not against the code/ dir where file mounts are actually written, so the
mount silently binds an empty directory instead of the configured content.

Pin --project-directory to code/ when building the docker compose command
so relative mounts always resolve the same way raw deployments already do.

Fixes #5181
2026-08-27 09:15:06 -06:00
Mauricio Siu 8f3230e2ed Merge pull request #5021 from Souvik-Cyclic/fix/railpack-deploy-sudo-5007
fix: run Railpack install with sudo during remote deploy
2026-08-27 09:10:47 -06:00
Mauricio Siu cebd380856 Merge pull request #5200 from Dokploy/fix/network-chart-labels-5115
fix: correct network monitoring chart labels and units
2026-08-27 01:47:16 -06:00
Mauricio Siu a822db5967 fix: correct network monitoring chart labels and units
Network chart showed values labeled as KB/s, but the underlying data
is MB totals accumulated since boot. Convert to GB and relabel as
Network Total (since Boot).

Fixes #5115
2026-08-27 01:44:49 -06:00
Mauricio Siu 71816d0853 chore: remove obsolete .mcp.json configuration file 2026-08-27 01:39:28 -06:00
Mauricio Siu 85ed75a5c6 Merge pull request #5199 from Dokploy/fix/org-form-unsaved-reset-window-focus-5146
fix: unsaved organization form fields reset on window visibility change
2026-08-27 01:31:42 -06:00
Mauricio Siu f46cd4601c fix: prevent unsaved organization form fields from resetting on window focus 2026-08-27 01:29:20 -06:00
Mauricio Siu 2a585933e9 Merge pull request #5198 from Dokploy/fix/network-sync-docker-id-5179
fix: detect network delete/recreate by Docker ID during sync
2026-08-27 01:17:52 -06:00
autofix-ci[bot] 62e6a4c017 [autofix.ci] apply automated fixes 2026-08-27 07:15:51 +00:00
Mauricio Siu 4710c28db5 docs: add CLAUDE.md code style notes 2026-08-27 01:15:17 -06:00
Mauricio Siu 63efbffcfd fix: detect network delete/recreate by Docker ID during sync
Network sync compared only by name, so a network deleted and
recreated with the same name but different attributes (driver,
attachable, etc.) was reported as already in sync.

Store Docker's network Id (dockerId column) and compare it against
the live Id during sync. A name match with a dockerId mismatch is
now surfaced as a new "changed" state, with an Update action to
refresh the DB row from the live network.

Fixes #5179
2026-08-27 01:15:10 -06:00
Mauricio Siu 523e8f4e4f Merge pull request #5197 from Dokploy/fix/env-file-var-interpolation-5151
fix: preserve ${VAR} interpolation in .env values
2026-08-27 00:54:45 -06:00
Mauricio Siu b67519ebd5 fix: preserve parameter expansion (${VAR:-default}) in .env interpolation 2026-08-27 00:54:02 -06:00
Mauricio Siu 452b7aa2f2 fix: preserve ${VAR} interpolation in .env files
Escaping in prepareEnvironmentVariablesForFile escaped every $,
including inside a deliberate ${VAR} reference, breaking Compose's
documented .env variable interpolation. Only escape $ that isn't
part of a ${IDENTIFIER} sequence.

Fixes #5151
2026-08-27 00:47:01 -06:00
Mauricio Siu 3c6a96a30a Merge pull request #5141 from Dokploy/fix/monitoring-zombie-container
fix(monitoring): remove legacy container and default empty cronJob
2026-08-27 00:03:36 -06:00
Narciso E. Núñez Arias 546686ea35 Merge pull request #5190 from somuai/fix/allow-plus-at-in-read-valid-directory
fix(server): allow '+' and '@' in readValidDirectory path validation
2026-08-26 12:07:29 -04:00
autofix-ci[bot] 217ca07842 [autofix.ci] apply automated fixes 2026-08-26 15:05:57 +00:00
Shuvo c6f81ce60d Merge branch 'Dokploy:canary' into canary 2026-08-26 15:10:36 +06:00
somuai bd8ba128cd fix(server): allow plus, at, and valid path characters in readValidDirectory 2026-08-26 04:54:13 +05:30
Mauricio Siu 72a554de5f chore: add script to spawn agent worktree with environment setup 2026-08-25 01:29:51 -06:00
Mauricio Siu cda047feb2 chore: add install-worktree-deps.sh script for dependency installation 2026-08-25 01:20:10 -06:00
Mauricio Siu feccb51287 Merge pull request #5183 from Dokploy/chore/worktree-automation
chore: automate git worktree setup
2026-08-25 01:03:38 -06:00
Mauricio Siu 1ccd633153 chore: add SKILL.md for issue reproduction and verification process
chore: create .mcp.json for MCP server configuration
2026-08-25 01:02:36 -06:00
Mauricio Siu 8be1e68fe1 chore: automate git worktree setup (shared node_modules, per-worktree dev port) 2026-08-25 00:59:44 -06:00
Narciso 875baa139c mend 2026-08-24 20:59:30 -04:00
Narciso e2ab2eb6bc remove unused code 2026-08-24 20:52:35 -04:00
Dokploy Bot d675db10bb chore: sync hotfix from main into canary [skip ci] 2026-08-24 15:40:57 +00:00
Narciso E. Núñez Arias 903789bfe5 Merge pull request #5160 from bestmaa/codex/fix-5145-traefik-empty-reconnect
fix(traefik): skip empty service reconnect

(cherry picked from commit d1273e2fe9)

[skip ci]
2026-08-24 15:40:52 +00:00
Narciso E. Núñez Arias d1273e2fe9 Merge pull request #5160 from bestmaa/codex/fix-5145-traefik-empty-reconnect
fix(traefik): skip empty service reconnect
2026-08-24 11:40:38 -04:00
outeiroDev 74d2d41961 feat: add Porkbun DNS provider support
Adds Porkbun as a supported DNS provider alongside Cloudflare and
AWS Route53, allowing Dokploy to automatically create DNS records
for domains managed on Porkbun.

- New DnsClient implementation for the Porkbun API v3
- porkbun enum value and config schema (apiKey/secretApiKey)
- Drizzle migration for the new DnsProviderType enum value
- UI: provider icon, form fields and provider selector entry
- Unit tests covering listZones/listRecords/upsertRecord/updateRecord/deleteRecord/testConnection
2026-08-22 21:50:37 +02:00
Aditya Nandlal ede4626396 fix(traefik): skip empty service reconnect 2026-08-22 08:02:21 +00:00
Dokploy Bot 772b768217 chore: sync hotfix from main into canary [skip ci] 2026-08-21 15:14:50 +00:00
Narciso E. Núñez Arias d3d8d9ec83 Merge pull request #5137 from bestmaa/fix/5111-preserve-log-container-selection
fix: preserve selected log container on refetch
(cherry picked from commit f4dfb6a903)

[skip ci]
2026-08-21 15:14:48 +00:00
Narciso E. Núñez Arias f4dfb6a903 Merge pull request #5137 from bestmaa/fix/5111-preserve-log-container-selection
fix: preserve selected log container on refetch
2026-08-21 11:14:37 -04:00
Narciso bbde5ebbc3 feat(permissions): add server.terminal to decouple server access from root SSH
Reaching a server implied being able to open an SSH root shell on it: the
/terminal websocket only checked that the server was in the caller's accessible
set, so granting a server to a developer necessarily granted root on it.

Add a server.terminal action, assignable on custom roles, and require it on the
remote-server terminal websocket on top of server access. Owner/admin keep it
through the enterprise bypass; the local host terminal stays owner/admin only.

A migration grants server.terminal to existing custom roles that already have
server.read, which is the permission that surfaces the terminal today, so
current setups keep working. Roles without a server entry are left alone.

Note: server.create still implies root execution (server.update persists
server.command and server.setup runs it over SSH), reflected in the Create
description in the role editor.
2026-08-21 11:08:31 -04:00
logical-tech 38e855bc5e fix(dns): preserve every value of a Route53 record set
Route53 returns a record set as a list of values, but listing joined them
into one string and writing sent that string back as a single
ResourceRecord. Editing a multi-value NS, MX or TXT set therefore either
failed validation or collapsed the set into one bogus value, and creating
a record for a name that already had values replaced them silently.

Values are now newline separated end to end: listing joins with a
newline, writing splits back into one ResourceRecord per line, and
creating merges into the existing set instead of replacing it. Unquoted
TXT values get the quotes Route53 requires. The record panel shows a
textarea for Route53 and validates every line.
2026-08-21 14:58:08 +02:00
Aditya Nandlal 62f701425a refactor: share log container selection 2026-08-21 12:32:49 +00:00
logical-tech 130be40b49 fix(dns): keep the record table on a valid page while filtering
Filtering from a later page left pageIndex past the end of the
filtered set, so the table said no records matched while the counter
above it reported matches. The page index is clamped to the available
page count, and changing the search or the type filter goes back to
the first page.
2026-08-21 11:47:29 +02:00
logical-tech db2eb4f60a fix(dns): send SRV and CAA values to Cloudflare as structured data
Cloudflare treats content as read-only for SRV and CAA and expects a
data object instead, so both types were rejected on write. The client
now parses the inline value into the fields Cloudflare wants, and
builds the payload before the lookup request so a malformed value
fails without spending an API call.

The form rejects a malformed SRV or CAA value up front and shows the
expected shape, so the error lands on the field instead of coming
back from the provider.
2026-08-21 11:47:29 +02:00
logical-tech 9c4ad14c70 feat(dns): rework provider management and support all record types
Providers, domains and records now each have their own page instead of
a stack of modals. A provider opens its domains as cards, with the
record count loading separately so the domains appear right away. A
domain opens its records as a table with search, type filter and
pagination. Creating or editing a record happens in a panel that
slides in next to the table.

The type list was capped at A and CNAME in the zod schema, so widening
the dropdown alone would not have worked. AAAA, MX, TXT, NS, SRV, CAA
and PTR work now. MX carries a priority that Cloudflare takes as a
separate field and Route53 takes inline in the value, so the
Cloudflare client splits it out on write and puts it back on read.
The form keeps one value field for both providers.

Cloudflare proxy status is now editable and visible. A, AAAA and CNAME
records get a Proxied / DNS only toggle in the form and a cloud icon
in the table. The proxy field only goes to the API when the caller
sets it, so an update from another path cannot silently disable the
proxy.

Tests cover the MX priority round trip and the proxy rules in the
Cloudflare client.
2026-08-21 10:24:52 +02:00
Narciso bee6918d9a fix(monitoring): remove legacy container and default empty cronJob
The swarm migration in 3848fa9c0 dropped the container.remove({force:true})
that the standalone deploy path used to run. Swarm tasks are named
dokploy-monitoring.<slot>.<id>, so there is no name collision and the
pre-v0.30.0 container survives every redeploy. It also stays pinned to an
orphaned image ID once pullRemoteImage moves the latest tag, so neither a
pull nor a Save clears it and it restarts forever.

Cloud setup spread metricsConfig straight from the row, shipping
cronJob: "" to the agent. robfig/cron rejects an empty spec, so the Go
binary exits before Fiber binds 4500 and Docker restarts it every ~60s.

- remove the legacy container in deployMonitoringService, which covers both
  setupMonitoring and setupWebMonitoring. Cleanup is best effort: a failure
  is logged and the deploy continues, matching the pre-migration behaviour
- default cronJob when configuring monitoring for cloud
- on build servers, clean up the legacy container but deploy no service.
  They never join the swarm, yet cloud setup did create the standalone
  container there before v0.30.0, and the monitoring form has always been
  hidden for them, so those agents are all stuck with an empty cronJob
- cover the above with real-docker tests
2026-08-20 11:17:41 -04:00
Aditya Nandlal d7c152af0d fix: preserve selected log container on refetch 2026-08-20 07:03:39 +00:00
Dokploy Bot 5ff0c26954 chore: sync hotfix from main into canary [skip ci] 2026-08-19 20:00:41 +00:00
Narciso E. Núñez Arias b4edb56132 Merge pull request #5116 from thaingnguyen/agent/fix-stack-log-container-selection
fix: preserve selected stack log container
(cherry picked from commit 9dab15ad47)

[skip ci]
2026-08-19 20:00:39 +00:00
Narciso E. Núñez Arias 9dab15ad47 Merge pull request #5116 from thaingnguyen/agent/fix-stack-log-container-selection
fix: preserve selected stack log container
2026-08-19 16:00:26 -04:00
Shuvo e3f4310755 Merge branch 'Dokploy:canary' into canary 2026-08-19 11:53:07 +06:00
Thai Nguyen 0be822ef5b refactor: keep stack log fix focused 2026-08-19 10:33:49 +07:00
Dokploy Bot a8e0477b70 chore: sync hotfix from main into canary [skip ci] 2026-08-18 22:40:23 +00:00
Narciso E. Núñez Arias 5fa963fe87 Merge pull request #5082 from bestmaa/fix/volume-backup-restart
fix(volume-backups): restart services after backup failure

(cherry picked from commit 3054cf53be)

[skip ci]
2026-08-18 22:40:21 +00:00
Narciso E. Núñez Arias 3054cf53be Merge pull request #5082 from bestmaa/fix/volume-backup-restart
fix(volume-backups): restart services after backup failure
2026-08-18 18:40:08 -04:00
Dokploy Bot 8c2d4d5c19 chore: release v0.30.2 2026-08-18 21:32:24 +00:00
Dokploy Bot 64c3a93590 chore: sync hotfix from main into canary [skip ci] 2026-08-18 21:22:28 +00:00
Mauricio Siu fa3cab8bcc Merge pull request #5125 from Dokploy/fix/issue-5096-stack-env-quotes
fix: don't quote .env values for stack deploys
(cherry picked from commit 31ddd7fe1a)

[skip ci]
2026-08-18 21:22:27 +00:00
Mauricio Siu 31ddd7fe1a Merge pull request #5125 from Dokploy/fix/issue-5096-stack-env-quotes
fix: don't quote .env values for stack deploys
2026-08-18 15:22:14 -06:00
Dokploy Bot edc8fc1d7f chore: sync hotfix from main into canary [skip ci] 2026-08-18 21:21:22 +00:00
Narciso E. Núñez Arias 8f42dced38 Merge pull request #5079 from NoiceHax/fix/issue-4074
fix(github-webhook): apply preview limit only to new previews

(cherry picked from commit fc6f4d9af8)

[skip ci]
2026-08-18 21:21:20 +00:00
Narciso E. Núñez Arias fc6f4d9af8 Merge pull request #5079 from NoiceHax/fix/issue-4074
fix(github-webhook): apply preview limit only to new previews
2026-08-18 17:21:08 -04:00
Mauricio Siu d183018201 fix: don't quote .env values for stack deploys
docker stack deploy reads env_file literally without stripping
quotes (unlike docker compose), so the quoting/escaping added to
fix #4694 was shipping literal quote characters into stack
containers.

Fixes #5096, #5110.
2026-08-18 15:20:55 -06:00
Dokploy Bot 7a0efc7aa4 chore: release v0.30.1 2026-08-18 20:53:40 +00:00
Narciso E. Núñez Arias effa3ea502 Merge pull request #4702 from Xeoneid/canary
feat(testing): add upgrade version integration test workflow
2026-08-18 16:37:55 -04:00
Barry Norman 80859dc5b6 feat(vault): add Phase.dev secrets provider
Enable deploy-time ${{vault.*}} resolution from Phase via the REST API
(Service Account token + SSE-enabled apps), matching existing Infisical/Doppler providers.

Fixes #5122

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:27:42 +02:00
Dokploy Bot b10cad4f78 chore: sync hotfix from main into canary [skip ci] 2026-08-18 20:22:29 +00:00
Mauricio Siu 65c5ccc7b2 Merge pull request #5118 from andwati/fix/tag-assignment-on-project-create
fix: assign tags created from the project dialog
(cherry picked from commit cf6f5e39d2)

[skip ci]
2026-08-18 20:22:27 +00:00
Mauricio Siu cf6f5e39d2 Merge pull request #5118 from andwati/fix/tag-assignment-on-project-create
fix: assign tags created from the project dialog
2026-08-18 14:22:12 -06:00
Dokploy Bot 33367fb86b chore: sync hotfix from main into canary [skip ci] 2026-08-18 20:21:28 +00:00
Mauricio Siu 5d1731c54f Merge pull request #5121 from Dokploy/fix/issue-5120-switch-thumb-position
fix: correct switch thumb translate-x for checked state
(cherry picked from commit e15b38be38)

[skip ci]
2026-08-18 20:21:25 +00:00
Mauricio Siu e15b38be38 Merge pull request #5121 from Dokploy/fix/issue-5120-switch-thumb-position
fix: correct switch thumb translate-x for checked state
2026-08-18 14:21:11 -06:00
Mauricio Siu 76c2416613 fix: correct switch thumb translate-x for checked state
Fixes #5120
2026-08-18 14:20:26 -06:00
Thai Nguyen 60612b0986 fix: reset stack log selection when switching modes 2026-08-18 22:10:54 +07:00
andwati 0ea7de7d31 fix: assign tags created from the project dialog
The tag dialog's form is nested inside the project dialog's form in the
React tree, and React propagates events along the React tree rather than
the DOM tree, so submitting the tag form also submitted the project form.
The project was created immediately with no tags and the dialog closed
before more than one tag could be picked.

Stop propagation in the tag form's onSubmit, and select the new tag in
the selector so it is included when the project is saved.

Fixes #5117
2026-08-18 14:18:39 +03:00
Thai Nguyen 49055a05b1 fix: surface native stack inspection failures 2026-08-18 17:52:46 +07:00
Thai Nguyen 2221b8a99c fix: preserve stack log selection on fetch failures 2026-08-18 17:47:59 +07:00
Thai Nguyen c77f6d889d fix: clear stale stack log container selection 2026-08-18 17:34:18 +07:00
Thai Nguyen a5b8626345 fix: preserve selected stack log container 2026-08-18 17:24:15 +07:00
Mauricio Siu fa788b00a6 Merge pull request #5114 from Dokploy/fix/issue-5108-changepassword-container-check
fix: false "container is not running" on database password change
2026-08-18 02:20:26 -06:00
Mauricio Siu 41d355c7dc fix: use dockerode API instead of shell docker ps for changePassword container lookup
Fixes #5108. The changePassword mutations for postgres/mysql/mariadb/mongo/redis
detected the running container via a shell one-liner (docker ps -q --filter
status=running | head -n1) executed through execAsync/execAsyncRemote. When that
shell command failed for any reason the CONTAINER_ID ended up empty and the
mutation incorrectly reported the container as not running even though it was.

Replaced it with getServiceContainer(), the existing dockerode-based lookup
already used by mount.ts and schedules/utils.ts for the same purpose.

Also fixes the postgres ALTER USER command, which never selected a database
and defaulted to a database named after the user, causing a false
'database "<user>" does not exist' failure whenever the db name differs
from the username.
2026-08-18 02:19:41 -06:00
Narciso E. Núñez Arias ded23bb05f Merge pull request #4660 from pparage/fix/ubuntu-26.04-docker-version
fix(setup): install Docker 29.4.2 on Ubuntu 26.04 to fix failed setup
2026-08-17 12:45:37 -04:00
Dokploy Bot 717792f186 chore: sync hotfix from main into canary [skip ci] 2026-08-17 08:06:53 +00:00
Mauricio Siu c0265b49b4 Merge pull request #5104 from Dokploy/fix/issue-5092-terminal-resize-desync
fix: sync terminal size with backend PTY (fixes #5092)
(cherry picked from commit b53fface69)

[skip ci]
2026-08-17 08:06:52 +00:00
Mauricio Siu b53fface69 Merge pull request #5104 from Dokploy/fix/issue-5092-terminal-resize-desync
fix: sync terminal size with backend PTY (fixes #5092)
2026-08-17 02:06:41 -06:00
Mauricio Siu ff275fe94e fix: sync terminal size with backend PTY, avoid connecting with placeholder container ID
Fixes #5092 — typing in the database/docker terminal overwrote text
instead of continuing on new lines. The xterm.js client never told the
backend PTY its actual size (node-pty/ssh2 always defaulted to 80x24
and were never resized), so whenever the rendered terminal width
differed from that, the remote shell's cursor math diverged from what
the client displayed.

- docker-container-terminal.ts / terminal.ts: pass the client's initial
  cols/rows into node-pty spawn and ssh2 exec/shell, and resize the PTY
  on a {type:"resize"} control message sent over the same ws channel.
- docker-terminal.tsx / settings/web-server/terminal.tsx: send cols/rows
  on connect and on every term.onResize, and watch the container with a
  ResizeObserver to refit on dialog/window resize.

Also fixes two related terminal/logs bugs surfaced while testing this:
- docker-terminal.tsx no longer opens a connection with the
  "select-a-container" placeholder ID before a real container is
  selected, and defers terminal creation a frame so React StrictMode's
  dev-only phantom mount doesn't hit a known xterm.js dispose race
  (xtermjs/xterm.js#5011, "Cannot read properties of undefined
  (reading 'dimensions')").
- docker-logs-id.tsx (shared by application/compose/compose-stack logs)
  had the same placeholder-ID issue, surfacing Docker's raw
  "No such container: select-a-container" daemon error instead of a
  proper empty state.
2026-08-17 02:03:47 -06:00
Dokploy Bot ca578ed075 chore: sync hotfix from main into canary [skip ci] 2026-08-17 07:43:04 +00:00
Mauricio Siu a691b469a1 Merge pull request #5103 from shuv-o/fix/project-permission-column-limit
fix: prevent Postgres 100-arg limit crash and restore data parity for restricted-member project access
(cherry picked from commit 6d8aa9d8da)

[skip ci]
2026-08-17 07:43:02 +00:00
Mauricio Siu 6d8aa9d8da Merge pull request #5103 from shuv-o/fix/project-permission-column-limit
fix: prevent Postgres 100-arg limit crash and restore data parity for restricted-member project access
2026-08-17 01:42:50 -06:00
Mauricio Siu 3b8611771c Merge pull request #4252 from JamBalaya56562/fix/tini-pid1-zombie-reap-v2
fix: reap HEALTHCHECK zombies by running tini as PID 1
2026-08-17 01:39:40 -06:00
Shuvo 2cb499fb64 fix: prevent Postgres 100-argument limit crash and restore data parity for restricted-member project access
The application table has 101 columns. The restricted-member branch of
project.one (apps/dokploy/server/api/routers/project.ts) queried the
applications relation with no columns narrowing, so Drizzle's relational
query builder generated a json_build_array call with one argument per
column, exceeding Postgres's FUNC_MAX_ARGS (100). Any non-owner/admin
member with limited access to a project containing at least one
application hit an opaque INTERNAL_SERVER_ERROR and got redirected away
from the project/environment page instead of seeing their project.

The same branch was also missing the "server" relation that the
owner/admin path (findProjectById) already includes, so restricted
members saw different (incomplete) data than owners/admins for the same
project.

Fixes this by extracting the existing serviceColumns column-selection
constant to a module-level export in packages/server/src/services/project.ts
and applying it (plus the server relation) to all 8 service relations in
the restricted-member query path, matching the owner/admin path.
2026-08-17 13:37:11 +06:00
Shuvo 479e8bb551 feat: refactor serviceColumns for project access and enhance data retrieval 2026-08-17 12:41:58 +06:00
Mauricio Siu e541308a4f Merge pull request #4979 from Dokploy/canary
🚀 Release v0.30.0
2026-08-14 10:12:33 -06:00
Mauricio Siu b976c7b4f7 Merge pull request #5085 from Dokploy/feat/signup-showcase-panel
feat: interactive feature showcase on the signup panel
2026-08-14 10:02:17 -06:00
Mauricio Siu 7f3a85ac38 Merge pull request #5084 from Dokploy/feat/track-cloud-signup
feat: track cloud sign_up event in dataLayer
2026-08-14 10:02:03 -06:00
Mauricio Siu ac9ad72875 feat: interactive feature showcase on the signup panel
Replaces the static quote in the onboarding layout's left panel with
an auto-rotating carousel of product highlights (Deploy / Manage /
Scale, 3 slides x 4 items), shown on the cloud signup page only.

- OnboardingLayout gets an optional leftPanel override, default
  quote untouched everywhere else (login, invitation, password reset).
- Auto-advances every 6s, pauses on hover/focus, respects
  prefers-reduced-motion, manual dot + arrow navigation.
- Copy pulled from the website's existing first-features.tsx section
  to stay consistent with approved marketing copy.
2026-08-14 09:24:54 -06:00
Mauricio Siu d8b7c1ea5e feat: track cloud sign_up event in dataLayer
Fires the sign_up dataLayer event already read by GTM for the three
signup paths on the cloud version: email/password (register.tsx),
GitHub/Google OAuth (via better-auth's newUserCallbackURL, distinct
from callbackURL so returning-user logins aren't counted), and
invitation acceptance. All gated behind isCloud, same as the rest of
the analytics wiring.
2026-08-14 09:09:27 -06:00
Mauricio Siu d542967ae4 Merge pull request #5083 from Dokploy/feat/cloud-tag-manager
feat: enable Google Tag Manager on cloud version only
2026-08-14 08:56:26 -06:00
Mauricio Siu aedb35b8df feat: enable Google Tag Manager on cloud version only
Mounts the existing cloud-gated Analytics component (GTM + HubSpot
script loader) in _app.tsx. It was already implemented behind
api.settings.isCloud but never wired into the app, so it never loaded
on cloud or self-hosted instances.
2026-08-14 08:44:50 -06:00
Aditya Nandlal c6ab76fe09 fix(volume-backups): preserve backup failure status 2026-08-14 12:51:28 +00:00
Aditya Nandlal 2598a62771 fix(volume-backups): restart services after backup failure 2026-08-14 11:40:21 +00:00
Mauricio Siu 8bc1bd36d8 style: format overview.ts (biome) 2026-08-14 00:51:15 -06:00
Mauricio Siu 1b9504897a Merge branch 'main' into canary
Resolve conflicts in docker.ts and domain.ts: canary already contains
a superset of main's hotfix cherry-picks (isDockerDesktop, isolated
network label pinning), just refactored/reformatted, so both sides
kept canary's version.

Also fix hotfix-cherry-pick.yml to sync each cherry-pick back into
canary immediately, so canary and main share history for that change
and the next canary->main release PR doesn't re-conflict on it.
2026-08-14 00:47:41 -06:00
Mauricio Siu 7b3c5355fa chore: update version to v0.30.0 in package.json 2026-08-14 00:40:40 -06:00
Mauricio Siu 1828e7c276 Merge pull request #5076 from Dokploy/feat/overview-dashboard
feat: add Overview dashboard (Services, Backups & Domains tabs)
2026-08-14 00:40:13 -06:00
Mauricio Siu d770e3ce7d feat: implement utility functions to streamline owner selection for backups and domains 2026-08-14 00:40:00 -06:00
autofix-ci[bot] d583ba8bd3 [autofix.ci] apply automated fixes 2026-08-14 06:26:26 +00:00
Mauricio Siu b8e8704493 refactor: adjust icon sizes and styles in overview components 2026-08-14 00:25:59 -06:00
Narciso 518ee44ea6 feat: move deployments into an overview page tab 2026-08-13 18:30:45 -04:00
Mauricio Siu f62e41ede6 Merge pull request #5072 from Dokploy/feat/docker-health-dashboard
feat: add Docker Health diagnostics tab
2026-08-13 15:07:01 -06:00
Narciso 046a608f8c feat: add Overview dashboard (Services, Backups & Domains tabs) 2026-08-13 17:03:42 -04:00
Mauricio Siu 171c70492d refactor: remove deployment failure classifier and related tests 2026-08-13 15:03:35 -06:00
Mauricio Siu a4425cfb1d Merge branch 'canary' into feat/docker-health-dashboard 2026-08-13 14:45:23 -06:00
Mauricio Siu 8e18a8a683 Merge pull request #5075 from Dokploy/fix/gitlab-oauth-missing-expires-in
fix(gitlab): handle missing expires_in in OAuth token response
2026-08-13 14:43:29 -06:00
Mauricio Siu fc5b8d03e5 fix(gitlab): handle missing expires_in in OAuth token response
Some self-hosted GitLab instances (e.g. older versions without
expires_in configured in doorkeeper) omit expires_in from the
OAuth token response. Computing Date.now()/1000 + undefined
produced NaN, which Postgres rejected on the expires_at integer
column, crashing both the initial OAuth callback and the token
refresh flow with a 500.

Falls back to null when expires_in is absent, matching the
existing Gitea callback behavior.

Closes #4362
2026-08-13 14:41:10 -06:00
Narciso E. Núñez Arias 1183c7c4a9 Merge pull request #4317 from difagume/fix(ui)--prevent-layout-shift
fix(ui): prevent layout shift in log counter using tabular-nums
2026-08-13 15:14:38 -04:00
JamBalaya56562 dec05aca22 fix: reap HEALTHCHECK zombies by running tini as PID 1
Node.js as PID 1 does not reap children it did not spawn, so the
HEALTHCHECK curl fired every 10s accumulates as <defunct>. Install
tini and set it as ENTRYPOINT so any orphaned child is reaped while
signals still reach Node.
2026-08-13 19:03:04 +00:00
NoiceHax 98dbc59a02 fix(github-webhook): apply preview limit only to new previews
The preview deployment limit was checked before looking up whether the
pull request already had a preview, so once an application reached the
limit every `synchronize` event was dropped and existing previews went
stale while GitHub still saw a 200. The check now runs inside the branch
that creates a preview, uses the schema default of 3 when no limit is
configured (instead of 0, which blocked every app without an explicit
value) and compares with `>=` so the configured limit is inclusive.

Closes #4074
2026-08-13 22:46:40 +05:30
Narciso 95b1afc2c2 fix: correct log-tail server resolution, drop unrelated Networks gating
- Log tail for failed-deployment classification now checks the app's
  current buildServer before falling back to its server, matching the
  buildServerId-then-serverId precedence used everywhere else in the
  codebase (application.ts, drop.ts, directory.ts, patch-repo.ts).
- Removes a stale !isCloud gate on the Networks tab that this branch
  had reintroduced from before Mauricio removed it in daba2d7fb
  (Networks is intentionally shown in cloud now) — unrelated to this
  PR's scope, docker.tsx's Networks tab/content now matches canary.
2026-08-13 12:03:38 -04:00
Narciso E. Núñez Arias 52aca4636d Merge pull request #4583 from TpmyCT/fix/deployments-open-tab
fix: open deployments tab when opening a service from the deployments list
2026-08-13 11:19:58 -04:00
Narciso c741ff36c0 fix: don't auto-run Health diagnostics on mount
Reverts the mount-only auto-refetch — it still ran host shell, Docker,
DB, and SSH log-tail work before the user asked for it, just once
instead of on every filter change. Back to fully manual: nothing runs
until Check/Re-check is clicked, matching the panel's own copy
("Nothing runs automatically — click to check").
2026-08-13 11:15:12 -04:00
Narciso 795065810d chore: trim multi-line comments to single lines
Tightens a handful of two-line comments left over from earlier
iterations (Health tab / failed-deployment classification) down to
one line each, no behavior change.
2026-08-13 10:27:02 -04:00
Narciso 4bf6dfff13 feat: add Docker Health diagnostics tab
Server diagnostics tab (Docker dashboard) with:
- Per-network IP usage: subnet capacity vs. containers in use, including
  reserved networks like dokploy-network which are excluded from the
  managed Networks tab. Surfaces read errors instead of silently showing
  zeroes when the Docker API call fails.
- Broadened daemon-error detection and failure classification for Swarm
  network failures: IP pool exhaustion on an existing network
  ("could not find an available IP", "task allocation failure") and
  network attach timeouts ("attaching to network failed", "context
  deadline exceeded") — both previously fell through as Unclassified.
  Verified end-to-end against a real systemd-managed dockerd.
- Failed-deployment rows now carry the raw log tail used for
  classification, shown on hover and linked to their service/project,
  so a guess (or lack of one) can be verified against the actual error
  and followed straight to the app.
- Daemon-error panel is a read-only code editor, always visible (not
  hidden when empty), prefixed with the actual "Logs from X to Y"
  window passed to journalctl so the --since window can be verified.
- "Download report" exports everything on screen (metrics + raw logs)
  as Markdown, so a customer can send one file instead of a screen-share.
- Health check now IS_CLOUD-gated the same way network sync already is:
  a cloud org can no longer omit serverId and have the check silently
  run against the shared platform host instead of their own server.

Includes an inotify diagnostics card (current vs. max_user_instances/
watches/queued_events) and a failed-deployments log with automatic
cause classification based on daemon/log error patterns.
2026-08-13 10:00:54 -04:00
Mauricio Siu 59ebf809aa Merge pull request #5071 from Dokploy/fix/scim-sso-account-linking
fix(auth): link SCIM-provisioned users through SSO and surface sign-in errors
2026-08-13 02:12:45 -06:00
Mauricio Siu a1a672b672 fix(auth): link SCIM-provisioned users through SSO and surface sign-in errors
SCIM provisioning created users with emailVerified: false, so better-auth
refused to link the matching SSO account (account_not_linked), and the
resulting redirect landed on better-auth's bare /error page with no
actionable feedback.

- Mark SCIM-provisioned users as email-verified at creation time, same
  trust rationale already applied to admin-invited credential users.
- Sync a SCIM user's membership role to the organization's configured
  default role instead of the hardcoded "member" the SCIM plugin creates.
- Set errorCallbackURL/onAPIError.errorURL to the sign-in page so failed
  SSO/OAuth callbacks land back on Dokploy's UI instead of better-auth's
  generic error page.
- Read the error query param on the sign-in page and show it through the
  existing AlertBlock instead of failing silently.

Fixes #4973
2026-08-13 02:11:15 -06:00
Mauricio Siu 92a1f0d0de Merge pull request #4841 from daniloneto/feat/session-management
feat: add session management page (list active sessions + revoke)
2026-08-13 01:32:03 -06:00
Mauricio Siu c61163d721 feat: update icons for Sessions and S3 Destinations in the sidebar menu 2026-08-13 01:31:19 -06:00
Mauricio Siu a2329aed26 feat: add user filtering to session management and update search functionality 2026-08-13 01:25:15 -06:00
Mauricio Siu f6c1e77279 feat: implement session management with filtering and sorting capabilities 2026-08-13 01:22:49 -06:00
Mauricio Siu dbe2b38ea9 Merge branch 'canary' into feat/session-management 2026-08-13 00:40:34 -06:00
Mauricio Siu eff6a1865e Merge pull request #5069 from Dokploy/fix/requests-scroll-lock-5064
fix: lock sidebar-inset scroll while a modal is open
(cherry picked from commit 90e96e1806)

[skip ci]
2026-08-13 06:11:48 +00:00
Mauricio Siu 90e96e1806 Merge pull request #5069 from Dokploy/fix/requests-scroll-lock-5064
fix: lock sidebar-inset scroll while a modal is open
2026-08-13 00:11:37 -06:00
Mauricio Siu dc9c67129e fix: lock sidebar-inset scroll while a modal is open
Radix locks scroll on <body>, but the dashboard's actual scroll
container is the sidebar's <main data-slot="sidebar-inset">, so the
background stayed scrollable behind dialogs/sheets. Extend the lock
to that container using the same data-scroll-locked attribute Radix
already sets.

Fixes #5064
2026-08-12 15:38:39 -06:00
Narciso E. Núñez Arias 561fb43d42 Merge pull request #5066 from Souvik-Cyclic/fix/docker-terminal-clipboard-5065
fix: add clipboard addon to docker container terminal
2026-08-12 16:33:02 -04:00
Narciso 795f6f094e test: fix stale composeFile mock in domain enabled-filter test 2026-08-12 16:28:06 -04:00
autofix-ci[bot] 26c871946a [autofix.ci] apply automated fixes 2026-08-12 19:03:06 +00:00
Souvik Kumar 65198f6082 fix: add clipboard addon to docker container terminal
The docker container terminal did not load xterm's ClipboardAddon, so
pasting into it (e.g. a MariaDB shell) broke the cursor and left the
pasted text uneditable. The web server terminal already loads this addon;
this brings the container terminal in line with it.

Also dispose the terminal on cleanup so its addons don't leak on remount.

Fixes #5065
2026-08-12 23:43:05 +05:30
Narciso E. Núñez Arias 07f95a034f Merge pull request #4543 from linkthai/fix/mongo-replicaSets-false-after-update
FIX: replicaSets default to false after updating mongo due to default
(cherry picked from commit 2bda2ccc04)

[skip ci]
2026-08-12 16:49:56 +00:00
Narciso E. Núñez Arias 2bda2ccc04 Merge pull request #4543 from linkthai/fix/mongo-replicaSets-false-after-update
FIX: replicaSets default to false after updating mongo due to default
2026-08-12 12:49:42 -04:00
Narciso E. Núñez Arias 1c9a538457 Merge pull request #4328 from teoobarca/fix/tooltip-volumes-not-pruned
fix: tooltip incorrectly lists volumes among Daily Cleanup targets
2026-08-12 12:17:43 -04:00
Narciso E. Núñez Arias 4e18a64da2 Merge pull request #4710 from elijahdev0/fix/add-source-type-to-create-apis
fix: add sourceType to apiCreateCompose and apiCreateApplication
2026-08-12 12:08:18 -04:00
Narciso E. Núñez Arias 3e10a8dd6d Merge pull request #4795 from yusoofsh/fix/4794-preserve-compose-mounts
fix(compose): preserve raw remote mounts
2026-08-12 11:47:04 -04:00
Mauricio Siu 63edae4740 Merge pull request #5061 from Dokploy/feat/docker-images-disk-usage-tabs
feat: add Images and Disk Usage tabs to docker dashboard
2026-08-12 04:34:28 -06:00
Mauricio Siu c2071bbbfd feat: add Images and Disk Usage tabs to docker dashboard
- Images tab: list, inspect and delete docker images, with a
  force-delete fallback when an image is in use.
- Disk Usage tab: docker system df summary as stat cards plus a
  Build Cache table (docker system df -v) with a prune action.
2026-08-12 04:30:19 -06:00
Mauricio Siu 1325a0fccc Merge pull request #5060 from Dokploy/fix/aws-sdk-minimum-release-age-exclude
fix: disable minimumReleaseAge, it broke the Docker build on unrelated AWS SDK deps
2026-08-12 04:29:41 -06:00
Mauricio Siu 9412b52172 fix: disable minimumReleaseAge (broke builds on unrelated AWS SDK deps)
@aws-sdk/client-secrets-manager (pre-existing, unrelated to any recent
PR) transitively pulled @smithy/fetch-http-handler + @smithy/core
versions published the same day, with no older compatible version to
fall back to. AWS SDK v3 is dozens of interdependent packages often
published together same-day, so a global minimumReleaseAge with no
per-scope threshold (pnpm only supports all-or-nothing exclude) was
going to keep breaking builds on any lockfile touch. Commented out
rather than deleted -- left for whoever wants to revisit it, see
discussion on #4679.

Also lets @aws-sdk/client-route-53 and @aws-sdk/client-secrets-manager
resolve to their actual latest versions now that nothing blocks it.
2026-08-12 04:26:27 -06:00
Mauricio Siu 7825537964 Merge pull request #5059 from Dokploy/feat/docker-events-tab
feat: add Docker events tab to docker dashboard
2026-08-12 04:11:48 -06:00
Mauricio Siu 3be9cdfe65 feat: add go-to-page input to Docker events pagination 2026-08-12 04:11:32 -06:00
Mauricio Siu fa7a54b4eb feat: add sorting and pagination to Docker events table 2026-08-12 04:09:39 -06:00
Mauricio Siu daba2d7fbc feat: add Docker events tab to docker dashboard
Adds a new Events tab to /dashboard/docker showing Docker daemon
events (equivalent to `docker events`), polled via docker.getEvents
with a time-range selector and refresh button.

Also fixes a stale isCloud redirect that forced the Networks tab
back to Containers even though it is now shown in cloud.
2026-08-12 04:07:59 -06:00
Mauricio Siu b07d7f2005 Merge pull request #5058 from Dokploy/fix/dns-provider-post-merge-fixes
fix: remove Route53 endpoint override (SSRF) and fix CI build
2026-08-12 04:07:31 -06:00
Mauricio Siu 02edd6abe7 fix: remove Route53 endpoint override and pin AWS SDK to an aged version
The endpoint field let anyone with dnsProvider create permission point
the AWS SDK at an arbitrary URL (loopback, link-local, internal
network, cloud metadata), turning testConnection and every other
Route53 operation into an SSRF oracle. There's no way to keep
LocalStack-style testing working while blocking that, since the
target address is identical either way, so the field is removed
entirely rather than validated. Route53 always talks to real AWS now.

Also pin @aws-sdk/client-route-53 to ^3.1097.0 (matching the existing
client-secrets-manager pin) instead of whatever caret-latest resolved
to at install time. The version that landed in the merged PR was
published less than a day before this, which violates the
minimumReleaseAge policy that merged into canary around the same time
and broke the Docker build.
2026-08-12 04:01:01 -06:00
Mauricio Siu a60b0af419 Merge pull request #5057 from Dokploy/feat/dns-provider-integration
feat: add DNS provider integration (Cloudflare, AWS Route53)
2026-08-12 03:47:41 -06:00
Mauricio Siu 9687224152 Merge pull request #4679 from vikyw89/harden/minimum-release-age
chore: enforce 3-day minimum release age for dependencies (supply-chain hardening)
2026-08-12 03:46:15 -06:00
agentHits 86bce3335f ci: update pull request actions for node 24 (#4773)
* ci: update pull request actions for node 24

Что:
- Обновлены actions checkout, setup-node и pnpm/action-setup в Pull Request workflow до v5.
Зачем:
- Убрать Node.js 20 deprecation annotations из pr-check jobs и использовать actions с Node.js 24 runtime.
Риски:
- Не выявлены; изменение ограничено версиями GitHub Actions без изменения команд workflow.
Проверки:
- Команды и результаты: gh api action.yml для actions/checkout@v5, actions/setup-node@v5 и pnpm/action-setup@v5 подтвердил runs.using node24; git diff --check прошел; ruby YAML parse .github/workflows/pull-request.yml вернул yaml ok; go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/pull-request.yml прошел без diagnostics; независимый Agent Flow reviewer вернул pass.
- Ограничения: Локальные build/test/typecheck не запускались, потому что изменение касается только GitHub Actions workflow versions.

What:
- Updated checkout, setup-node, and pnpm/action-setup actions in the Pull Request workflow to v5.
Why:
- Remove Node.js 20 deprecation annotations from pr-check jobs and use actions with the Node.js 24 runtime.
Risks:
- None identified; the change is limited to GitHub Actions versions without changing workflow commands.
Checks:
- Commands and results: gh api action.yml for actions/checkout@v5, actions/setup-node@v5, and pnpm/action-setup@v5 confirmed runs.using node24; git diff --check passed; ruby YAML parse .github/workflows/pull-request.yml returned yaml ok; go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/pull-request.yml passed with no diagnostics; independent Agent Flow reviewer returned pass.
- Limitations: Local build/test/typecheck were not run because the change only touches GitHub Actions workflow versions.

* ci: update format actions for node 24

Что:
- Обновлены actions/checkout и pinned autofix-ci/action в format workflow до Node.js 24-compatible версий.
Зачем:
- Убрать оставшуюся Node.js 20 deprecation annotation из autofix.ci после исправления pr-check workflow.
Риски:
- Не выявлены; изменение ограничено версиями GitHub Actions без изменения formatter-команды.
Проверки:
- Команды и результаты: gh api action.yml для autofix-ci/action@v1.3.4 подтвердил runs.using node24; git ls-remote подтвердил SHA c5b2d67aa2274e7b5a18224e8171550871fc7e4a для tag v1.3.4; git diff --check прошел; ruby YAML parse для pull-request.yml и format.yml вернул yaml ok; go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/pull-request.yml .github/workflows/format.yml прошел без diagnostics; независимый Agent Flow reviewer подтвердил корректность полного local diff и отметил, что fix нужно опубликовать.
- Ограничения: Локальные build/test/typecheck не запускались, потому что изменение касается только GitHub Actions workflow versions.

What:
- Updated actions/checkout and the pinned autofix-ci/action in the format workflow to Node.js 24-compatible versions.
Why:
- Remove the remaining Node.js 20 deprecation annotation from autofix.ci after fixing the pr-check workflow.
Risks:
- None identified; the change is limited to GitHub Actions versions without changing the formatter command.
Checks:
- Commands and results: gh api action.yml for autofix-ci/action@v1.3.4 confirmed runs.using node24; git ls-remote confirmed SHA c5b2d67aa2274e7b5a18224e8171550871fc7e4a for tag v1.3.4; git diff --check passed; ruby YAML parse for pull-request.yml and format.yml returned yaml ok; go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/pull-request.yml .github/workflows/format.yml passed with no diagnostics; independent Agent Flow reviewer confirmed the full local diff is correct and noted that the fix needed to be published.
- Limitations: Local build/test/typecheck were not run because the change only touches GitHub Actions workflow versions.
2026-08-12 03:38:45 -06:00
Mauricio Siu 37f0bba43f fix: resolve @ to the zone's apex domain before sending it to the provider
Route53 rejects the literal "@" (RRSet with DNS name @. is not
permitted); Cloudflare happens to accept it, but relying on
provider-specific handling of that shorthand isn't safe. Resolve it
client-side to the zone's actual domain name before building the
create/update payload.
2026-08-12 03:35:11 -06:00
Mauricio Siu 16f0f0eeb4 test: add dnsProvider to the enterprise-resources coverage list 2026-08-12 03:31:20 -06:00
Mauricio Siu 4d58f3987f feat: add DNS provider integration (Cloudflare, AWS Route53)
Lets you connect a DNS provider and manage its records (create,
update, delete) from Settings -> DNS Providers, instead of doing it
by hand in Cloudflare/AWS.

- dns_provider table, org-scoped, jsonb config as a discriminated
  union per provider type
- Cloudflare adapter (REST, bearer token)
- Route53 adapter (AWS SDK, SigV4); records are identified by
  type:name since Route53 has no native record id, so update
  handles renames as delete-old + upsert-new
- listZones/listRecords/createRecord/updateRecord/deleteRecord/
  testConnection wired through a shared DnsClient interface
- Settings UI: provider management, zone browser, record CRUD,
  IP-fill dropdown for A records (panel IP + remote servers)
- Access control: dnsProvider resource wired into custom roles
- Unit tests for both adapters and the config mask/merge logic
2026-08-12 03:25:25 -06:00
Mauricio Siu 3848fa9c0d fix: deploy monitoring as a swarm service instead of a standalone container (#5055)
Monitoring was the only component still using docker.createContainer
directly instead of docker.createService like every other Dokploy
component (postgres, traefik, forward-auth, etc). This meant it never
benefited from Swarm's own reconciliation, relying only on Docker's
restart policy with no self-healing if the daemon didn't come back
cleanly after a reboot.

Also removed the try/catch that silently swallowed setup errors,
which made the UI report success even when the container/service
failed to start.
2026-08-12 01:36:16 -06:00
Mauricio Siu f12ecc3350 fix: don't cancel schedule deployments running outside the panel process on restart (#5053)
Fixes #4986. initCancelDeployments blindly marked every 'running'
deployment as 'cancelled' on boot, including schedule runs whose
actual work (docker exec into another container, or SSH to a remote
host) is decoupled from the Dokploy process and keeps running after
a restart. Now only deployments with no schedule, or schedules of
type dokploy-server (a real child process), get cancelled.

Also resolve any stale 'running' deployment for a schedule when a
new run starts, so restarted panels don't leave ghost 'running' rows
forever.
2026-08-11 21:33:49 -06:00
Mauricio Siu 3cad115155 feat: make organization selector larger and searchable (#5052)
Replaces the organization dropdown with a Popover + Command combobox: wider panel and a search input to filter organizations by name.

Closes #5043
2026-08-11 20:55:26 -06:00
Mauricio Siu 34c5b688ac fix: docker cleanup hangs silently on false-positive busy-wait match (#5051)
dockerSafeExec's busy-wait matched any process with "docker <letter>"
anywhere in its argv (e.g. Grafana's --packaging=docker cfg:...),
causing the wait loop to spin forever. Anchor the match to the actual
docker binary (argv[0]) instead, and add a MAX_WAIT ceiling so a
genuinely stuck docker process can't hang cleanup indefinitely either.

Also log failures in cleanupAll's catch instead of swallowing them,
since the scheduled cleanup path only goes through this function.

Fixes #5044
2026-08-11 20:40:24 -06:00
Mauricio Siu 3fbd67fe4d fix: invalid replacement placeholder in redirect form (#5050) 2026-08-11 20:22:59 -06:00
Mauricio Siu 491054f1d9 Merge pull request #5046 from Quentin-Gillet/feat/scaleway-secret-manager
feat(vault): add Scaleway Secret Manager provider
2026-08-11 20:15:28 -06:00
Mauricio Siu 5492ed12a0 Add new journal entry for "0184_wonderful_absorbing_man" with version 7 2026-08-11 20:02:29 -06:00
Mauricio Siu cba6ce9c57 Merge branch 'canary' into feat/scaleway-secret-manager 2026-08-11 20:02:08 -06:00
Mauricio Siu 8bf1167530 Remove deprecated entry for legal_hydra from journal.json 2026-08-11 20:01:44 -06:00
Narciso 529f54e2a5 fix(api): reject non-integer body size env vars 2026-08-11 15:06:02 -04:00
Narciso 8144e1a2fe fix(api): validate OpenAPI body size limits against Greptile findings 2026-08-11 14:57:42 -04:00
Narciso d11735d4cd fix(api): configurable body size limits for OpenAPI catch-all route 2026-08-11 14:24:35 -04:00
Narciso E. Núñez Arias 739fd08372 Merge pull request #4584 from andershermansen/fix/upgrade-traefik-3.6.20
fix(security): update Traefik from v3.6.7 to v3.6.25

(cherry picked from commit 870d592a97)

[skip ci]
2026-08-11 17:32:12 +00:00
Narciso E. Núñez Arias 870d592a97 Merge pull request #4584 from andershermansen/fix/upgrade-traefik-3.6.20
fix(security): update Traefik from v3.6.7 to v3.6.25
2026-08-11 13:31:54 -04:00
Narciso E. Núñez Arias 56fdb6151d Merge pull request #4697 from gentslava/feat/domain-enable-disable
feat(domains): add toggle to enable/disable domains
2026-08-11 13:30:53 -04:00
Vyacheslav Scherbinin a089870e8b test(compose): enable domain in security fixture 2026-08-11 23:03:53 +07:00
Vyacheslav Scherbinin 5bcaf9dafb Merge remote-tracking branch 'upstream/canary' into feat/domain-enable-disable
# Conflicts:
#	apps/dokploy/drizzle/meta/0182_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2026-08-11 22:59:41 +07:00
Quentin Gillet f238347ed7 feat(vault): add Scaleway Secret Manager provider
Adds Scaleway Secret Manager as a first-class vault provider alongside
HashiCorp Vault/OpenBao, Infisical, AWS, Doppler and Azure Key Vault.

- New `scaleway` provider type, zod-validated config (region, project ID,
  API secret key, overridable API URL) and migration adding the enum value
- Plain `fetch` client (no new dependency) against the Secret Manager
  v1beta1 REST API: access-by-path for reads, paginated listing for the
  env editor autocomplete, and a connection test
- Refs are `[folder/]name[:field]` — the field selector extracts a key
  from JSON/key-value secrets, like the AWS provider
- Secret key masked in API responses like every other provider credential
- Settings -> Secrets form with region picker, brand icon and reference
  format help
2026-08-11 16:21:32 +01:00
Mauricio Siu 9919e369d1 Merge pull request #5042 from Dokploy/feat/default-org-role
feat(organization): default role for new members
2026-08-11 02:23:10 -06:00
Mauricio Siu aaf8d7e12f feat(organization): allow setting a default role for new members 2026-08-11 02:21:33 -06:00
Mauricio Siu 1ccf15f331 Merge pull request #5041 from Dokploy/refactor/cluster-nodes-docker-tab
refactor: move cluster nodes into Docker dashboard as a Swarm sub-tab
2026-08-11 02:05:33 -06:00
Mauricio Siu 4732b38d5a refactor: align cluster/swarm read queries with docker.read, gate node actions by server permissions 2026-08-11 02:04:09 -06:00
Mauricio Siu c76c5b2f90 refactor: show nodes tab to any user with docker.read 2026-08-11 02:02:24 -06:00
Mauricio Siu c6c259f926 refactor: add icon support for deployments and services in UI components 2026-08-11 02:01:21 -06:00
Mauricio Siu 1617d07fbd refactor: move cluster nodes into docker dashboard swarm tab
- Add Nodes sub-tab under Docker > Swarm (visible with organization.update permission)
- Remove Cluster item from settings sidebar and delete /dashboard/settings/cluster page
- Point internal cluster links to /dashboard/docker?tab=swarm&subtab=nodes
- Make ShowNodes card full-width to match the other docker tabs
2026-08-11 01:55:42 -06:00
Mauricio Siu e237e34e14 Merge pull request #5040 from Dokploy/feat/typescript-7
feat: upgrade to TypeScript 7 and Next.js 16.3
2026-08-11 01:44:32 -06:00
Mauricio Siu 71521f2711 feat: upgrade to TypeScript 7 and Next.js 16.3 2026-08-11 01:39:09 -06:00
Anders Semb Hermansen 36db6f589a fix(security): update Traefik to v3.6.25 2026-08-11 08:36:36 +02:00
Anders Semb Hermansen 3cefade4a0 Merge remote-tracking branch 'origin/canary' into fix/upgrade-traefik-3.6.20 2026-08-11 08:35:23 +02:00
Vyacheslav Scherbinin 5ceb88bebe fix(compose): regenerate mapping domain labels 2026-08-11 12:29:49 +07:00
Vyacheslav Scherbinin 92b628c8fc fix(domains): add status tooltip to table 2026-08-11 12:19:03 +07:00
Narciso E. Núñez Arias 790d7c1f36 Merge pull request #4820 from techdawnarea/mariaDB-color-fix
MariaDB svg color updated: in Dark theme its not visible properly

(cherry picked from commit 00d9603209)

[skip ci]
2026-08-11 04:42:25 +00:00
Narciso E. Núñez Arias fe85f0f998 Merge pull request #4828 from Dokploy/fix/yaml-editor-auto-indent
fix(editor): consistent YAML auto-indent on newline

(cherry picked from commit cf22a0e058)

[skip ci]
2026-08-11 04:41:05 +00:00
Narciso E. Núñez Arias d39902d575 Merge pull request #4791 from PixelPerfector/fix/remote-threshold-server-name
fix(notifications): use real server name in remote threshold alerts

(cherry picked from commit 79b5ff96ca)

[skip ci]
2026-08-11 04:22:23 +00:00
Vyacheslav Scherbinin 67fa8e4104 fix(compose): clean domain labels across services 2026-08-11 11:16:44 +07:00
Narciso E. Núñez Arias c085ec3793 Merge pull request #4985 from Dokploy/fix/sync-version-release-spec
fix(ci): generate OpenAPI spec from the release commit in sync-version

(cherry picked from commit f9057d9304)

[skip ci]
2026-08-11 04:15:49 +00:00
Vyacheslav Scherbinin f14f12c778 fix(compose): clean up mapping domain labels 2026-08-11 11:02:49 +07:00
Vyacheslav Scherbinin 37c8d6477e fix(compose): remove stale labels for disabled domains 2026-08-11 10:45:33 +07:00
Narciso E. Núñez Arias 2003886aa0 Merge pull request #4967 from Dokploy/fix/restore-drop-use-statements
fix(restore): drop USE/CREATE DATABASE statements so mysql/mariadb restores target the selected database

(cherry picked from commit ce0fe63b4a)

[skip ci]
2026-08-11 03:42:59 +00:00
Narciso E. Núñez Arias 19958ee9d9 Merge pull request #4969 from Dokploy/fix/server-threshold-email
fix(notifications): send server threshold alerts through email, resend, gotify and ntfy

(cherry picked from commit 218687f46d)

[skip ci]
2026-08-11 03:29:34 +00:00
Narciso 63144e6855 Merge branch 'canary' into feat/domain-enable-disable 2026-08-10 22:38:27 -04:00
autofix-ci[bot] c8b87c7105 [autofix.ci] apply automated fixes 2026-08-11 02:15:03 +00:00
Mauricio Siu bd92dfb8c1 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
(cherry picked from commit 4791863ff9)

[skip ci]
2026-08-09 18:11:38 +00:00
Mauricio Siu 692c68f013 Merge pull request #5024 from Dokploy/fix/stack-containers-worker-nodes-4520
fix: show stack containers running on worker nodes
(cherry picked from commit b7db28408d)

[skip ci]
2026-08-09 17:38:10 +00:00
Souvik Kumar 679dbf98cb fix: run Railpack install with sudo during remote deploy
Deploy-time Railpack install ran without sudo, so deploys to a remote
server with a non-root user (passwordless sudo) failed because the
install script could not write to /usr/local/bin ("A terminal is
required to authenticate").

Detect whether sudo is needed (root vs. passwordless-sudo user) and run
the install through it, matching the server-setup script.

Fixes #5007
2026-08-09 20:01:06 +05:30
Mauricio Siu b1795e1d3c 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
(cherry picked from commit f08c53c9b7)

[skip ci]
2026-08-09 05:37:20 +00:00
Shivam Gupta 6204415dc5 fix: honour metaName for page titles and show a loading state in requests
Two cases where the UI silently did nothing:

- DashboardLayout declared a `metaName` prop and 18 settings pages pass
  it, but the component only destructured `children`, so the value was
  discarded. None of those pages render their own <Head> either, so every
  settings page fell back to the default title from _app. The layout now
  renders a <title>, using the whitelabeling app name so the suffix is
  correct on rebranded instances.

- The requests table's fallback cell only rendered its message when
  `statsLogs?.data.length === 0`. While the query is in flight statsLogs
  is undefined, so that guard is false and the cell rendered nothing at
  all -- a blank panel, indistinguishable from a broken page. The query's
  isLoading flag wasn't even destructured. It now shows a spinner, matching
  the queue and deployments tables.
2026-08-09 01:01:41 +05:30
Narciso E. Núñez Arias 79af8da827 Merge pull request #4171 from rpoetrap/fix/docker-host
fix: invalid docker host while using docker desktop
(cherry picked from commit 1de7b515ab)

[skip ci]
2026-08-07 19:47:05 +00:00
Narciso E. Núñez Arias 6293a39187 Merge pull request #4997 from ews-pgasser/fix/chaining-canary
fix: allow docker compose command chaining
(cherry picked from commit 91258d00ad)

[skip ci]
2026-08-07 14:34:29 +00:00
Mauricio Siu d3a873a6f5 Merge pull request #5004 from Dokploy/fix/isolated-deployment-traefik-network-label
fix: pin Traefik routing to the isolated network on isolated deployments
(cherry picked from commit bcab5b059f)

[skip ci]
2026-08-07 07:14:59 +00:00
Narciso E. Núñez Arias 54272b95b6 Merge pull request #3867 from karyanayandi/fix/lazy-initializer-for-usestate
fix: use lazy initializer for useState with function calls
(cherry picked from commit 27377da6fa)

[skip ci]
2026-08-06 15:50:29 +00:00
Narciso E. Núñez Arias 6ec530043e Merge pull request #4122 from Gabrielgvl/fix/server-schedule-pipefail
fix: preserve server schedule failures through tee pipeline
(cherry picked from commit 3e306e5dba)

[skip ci]
2026-08-06 15:21:37 +00:00
Narciso E. Núñez Arias 8545a1d9e7 Merge pull request #4176 from daniel-abramov/canary
fix: add unzip to arch server set up
(cherry picked from commit d8bb3f406c)

[skip ci]
2026-08-06 15:13:58 +00:00
Mauricio Siu c9ca719d97 Merge pull request #4984 from Dokploy/fix/sync-version-pnpm-pin
fix(ci): pin pnpm and strip v prefix in sync-version job

(cherry picked from commit 53feb9dd5b)

[skip ci]
2026-08-06 07:20:48 +00:00
Mauricio Siu b50b1d4c86 Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars
fix: preserve special characters in Compose-generated .env values
(cherry picked from commit abe774650f)

[skip ci]
2026-08-06 06:58:20 +00:00
Mauricio Siu 98af5a2f07 fix(ci): create release tags on the pushed commit instead of default branch
(cherry picked from commit 472ebed06c)

[skip ci]
2026-08-06 00:43:52 -06:00
Mauricio Siu 5ed94954e6 Merge pull request #4972 from Dokploy/fix/domain-single-label-hostname
fix(domain): allow single-label hostnames without a TLD

(cherry picked from commit 75448f358e)

[skip ci]
2026-08-06 06:33:27 +00:00
Jörn Bungartz 09ea766fb5 fix(server): sanitize sshd config values in security audit JSON output
Duplicate directives in sshd_config (e.g. two active PermitRootLogin
lines from Match blocks or provisioning scripts) made the grep|awk
captures multi-line. The embedded newline ended up inside a JSON string
literal, so JSON.parse failed with "Bad control character in string
literal" and the Security tab showed a red banner plus default values.

Take the first match (mirroring sshd's first-match-wins semantics) and
strip carriage returns from CRLF-edited configs.
2026-07-21 18:35:16 -06:00
Danilo Neto 7728e5ea9e feat: add session management page (list active sessions + revoke)
Add a new settings page under Settings > Sessions that shows all active
and expired login sessions with user info, IP address, device, and an
option to revoke (force logout) any session.

Backend:
- listSessions: adminProcedure that returns sessions joined with user
  data, ordered by creation date
- revokeSession: adminProcedure that deletes a session by ID (blocks
  self-revocation)

Frontend:
- New /dashboard/settings/sessions page with ShowSessions component
- Sessions table with user, IP, device, timestamps, and revoke action
- Sidebar link under Users (gated by member.read permission)

Tests:
- Input validation tests for revokeSession schema
2026-07-17 13:48:54 +00: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
Yusoof Moh e43d99e796 fix(compose): Preserve raw remote mounts
Use the saved raw compose source during conversion instead of reading a potentially stale remote materialization. Preserve tmpfs declarations and long-syntax volume mounts through preview and stack deployment.

Fixes #4794

Co-Authored-By: Codex <noreply@openai.com>
2026-07-11 13:03:30 +07:00
autofix-ci[bot] 5344945f83 [autofix.ci] apply automated fixes 2026-07-09 09:37:31 +00:00
Yash Kumar f23b5b9532 fix(typecheck): fix twoFactorRedirect typescript error 2026-07-09 15:05:51 +05:30
Yash Kumar 79baa24763 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 14:53:50 +05:30
Yash Kumar 6dc22bdeed Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-09 14:52:25 +05:30
Yash Kumar ac2e081134 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 00:01:19 +05:30
Yash Kumar 26d716b34f fix: eliminate FOUC for whitelabeling across all pages 2026-07-08 23:59:02 +05:30
Yash Kumar 2a7668662f Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-08 22:28:31 +05:30
autofix-ci[bot] 49bf39dc18 [autofix.ci] apply automated fixes 2026-07-08 05:00:40 +00:00
Yash Kumar 0393c2635b feat: add drag-and-drop logo upload for organizations
- Integrated Dropzone component into handle-organization.tsx.
- Resolved dialog auto-close bug by decoupling Dialog from DropdownMenu trigger.
- Added DOMPurify to sanitize uploaded SVG logos preventing XSS.
- Implemented client-side compression via canvas to resize raster images to 256x256 WebP.
- Enhanced UX with instant logo preview, masked base64 input strings, and a quick-clear button.
- Updated Dropzone to accept classNameContent for local height overrides.
2026-07-08 10:17:05 +05:30
Roo 30c408b130 fix: add sourceType to apiCreateCompose and apiCreateApplication
apiCreateCompose and apiCreateApplication did not include sourceType in their .pick() schemas, causing every compose/application created via the API to default to 'github' in the DB. Users had to manually update the sourceType after creation.

sourceType is already defined as .optional() in both createSchemas, so this is backward-compatible — callers that don't send it will still get the DB default ('github').
2026-06-29 23:37:47 -04:00
Claude a504449f1d feat: add target_version input, replace extra_pairs field
- Replace second form field (extra_pairs) with target_version
- target_version sets version B ("to"); empty = highest available tag
- Pair every tag in [floor_version, target_version) with target_version
2026-06-26 13:11:38 -03:00
Claude eaec68023e feat: consecutive upgrade pairs, drop curated pair and extra_pairs input
- Replace "every version → latest" with consecutive pairs (v[i] → v[i+1])
- Remove curated v0.25→v0.26 pair logic
- Remove extra_pairs workflow_dispatch input
2026-06-26 13:11:38 -03:00
Claude 2643ae0a86 fix(ci): deploy postgres/mongo instead of start
The create step was calling postgres.start / mongo.start immediately after
create. In Dokploy, .start only scales an already-deployed Swarm service;
on a freshly created resource the service doesn't exist yet, so the server
ran 'docker service scale ci-pg-db-xxx=1' against a missing service and
returned a 500 (curl -sf → exit 22 → step failure).

.deploy is the mutation that actually builds and creates the Swarm service.
Applications already used .deploy correctly; this aligns the databases.
2026-06-26 13:11:38 -03:00
Claude f59f11474b fix(ci): raise floor to v0.29.4, require environmentId from project.create
v0.20.x used a completely different postgres/mongo/app API schema —
projectId fallback returned boolean false, not the expected resource object.
Since the goal is testing v0.29.4+, drop the old-API compatibility shim
and fail fast with a clear message if environmentId is missing.

Also: default floor_version changed from v0.20.0 to v0.29.4 to reduce
the number of matrix jobs and focus on the stable modern API surface.
2026-06-26 13:11:37 -03:00
Claude f29a1a1976 fix(ci): handle both old (flat) and new (nested) project.create response formats
In Dokploy <= ~v0.24, project.create returns a flat object with just
projectId at the top level; no environment concept exists yet, so
resources (postgres, mongo, apps) are created with projectId.

In Dokploy >= ~v0.25, project.create returns nested {project, environment}
objects and resources require environmentId.

Previous workflow always used .project.projectId and .environment.environmentId,
both of which evaluated to null on old versions, causing curl to exit with
code 22 (HTTP error) on every subsequent resource-create call.

Fix: extract PROJECT_ID with a // fallback, check ENV_ID nullness, and
build a SCOPE fragment (either 'environmentId' or 'projectId') used in
all resource-creation tRPC calls.
2026-06-26 13:11:37 -03:00
Claude ade7a79bf1 fix(ci): run dokploy install.sh as root, install version A directly via DOKPLOY_VERSION
Previous run failed with 'This script must be run as root'. install.sh also
respects DOKPLOY_VERSION and ADVERTISE_ADDR env vars, so we can install
version A directly instead of installing latest and downgrading (which
would risk B's migrations running on A's expected schema).
2026-06-26 13:11:37 -03:00
Blas 55704ff98f feat: add upgrade integration test workflow (manual, matrix-based) 2026-06-26 13:11:37 -03:00
vikyw89 57bb9f27bd chore: enforce 3-day minimum release age for dependencies
Supply-chain hardening. pnpm 10.16+ native minimumReleaseAge refuses to
resolve any dependency version published less than 3 days ago (4320 min),
so newly-published malicious versions get caught/yanked before we install.
Workspace-wide, so it covers root, apps/* and packages/server. Only gates
new resolutions; versions already pinned in the lockfile are unaffected.

Closes #4678

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:54:10 +00:00
Philippe Parage b9a8064371 fix(setup): use Docker 29.4.2 on Ubuntu 26.04 to fix install failure
The provisioning script pins DOCKER_VERSION=28.5.0 and passes it to
get.docker.com via --version. Ubuntu 26.04 (resolute) ships a docker-ce
repo with no 28.5.0, so the pin is not found in apt-cache madison and
Docker is never installed, aborting setup with 'Failed to initialize
Docker Swarm'.

Repin to a version present on every architecture Docker ships resolute
for. amd64/arm64/armhf start at 29.3.1, but the s390x pool only carries
29.4.0-29.4.2, so 29.3.1 still fails there; 29.4.2 is the newest version
common to all shipped arches (ppc64le is not shipped for resolute). The
override applies to Ubuntu 26.04 only; all other releases keep 28.5.0,
and it runs before the install banner so the reported version stays
accurate and both the full-server and build-server paths are covered.

Fixes #4327, #4349. Closes #4501.
2026-06-17 21:21:16 +02:00
Anders Semb Hermansen cee674235f fix(security): update Traefik from v3.6.7 to v3.6.20 2026-06-09 07:54:55 +02:00
TpmyCT 60ad78282e fix: open deployments tab when opening a service from the deployments list
The Open action in the centralized deployments list navigated to the service's General tab instead of its Deployments tab. Append `?tab=deployments` to the generated link so it deep-links straight to the Deployments tab, matching the redirect the app already performs after a deploy.
2026-06-08 22:57:29 -03:00
thaivlinh 3cb0523681 FIX: replicaSets default to false after updating mongo due to default
set by create schema
2026-06-04 02:12:22 +07:00
Hamper 4032895176 fix: tooltip incorrectly lists volumes among Daily Cleanup targets
The Daily Docker Cleanup tooltip lists `volumes` among pruned
resources, but `cleanupAll` in `packages/server/src/utils/docker/utils.ts`
excludes the volume prune via `excludedCleanupAllCommands` (added in #3267).

Removes `volumes` from the tooltip to match actual behavior.
2026-04-29 09:49:15 +02:00
diego fabricio bb0c59fc5c fix(ui): prevent layout shift in log counter using tabular-nums 2026-04-27 09:50:46 -05:00
Laode Muhammad Al Fatih c668f6c7bd fix: add env -i PATH prefix to fresh volumes down command
Consistent with the rest of the codebase where every docker compose
invocation uses `env -i PATH="$PATH"` for a clean environment.
2026-04-10 16:26:24 +07:00
Laode Muhammad Al Fatih 36ba9fcb3e feat: add "Deploy with Fresh Volumes" button for Docker Compose projects
Adds a one-click option to redeploy Docker Compose services with clean
volumes, removing the need to manually edit the run command.

When triggered, it runs `docker compose down --volumes` before the
standard deploy build step. The feature is only available for
docker-compose type projects (not swarm stacks).
2026-04-10 16:18:37 +07:00
Romain PINSOLLE 24cc455168 fix: improve responsive layout for application logs page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:17:09 +02:00
312 changed files with 103546 additions and 2970 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"worktree": {
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
}
]
}
]
}
}
+55
View File
@@ -0,0 +1,55 @@
---
name: fix-issue
description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
state (create a project, an app, an env var) and to verify backend behavior.
Search for the tool you need; they are not all loaded upfront.
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
a user would see or click.
Pick by where the bug lives, not by convenience:
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
The API returning correct data proves nothing here.
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
A green screenshot proves nothing here.
- Unclear → do both.
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
click through ten screens to create a project the API can create in one call.
## Steps
1. Run `gh issue view $1` and read the full issue, including comments.
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
comment on the issue explaining what you tried and STOP.
Do not implement anything.
3. Implement the fix. Keep the change minimal and scoped to the issue.
4. Run `pnpm test`, then re-run the same reproduction from step 2.
5. Only if both pass: commit and run `gh pr create`. The PR description must
include the before/after reproduction steps and reference the issue.
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
@@ -19,4 +19,4 @@ jobs:
- name: Run Biome formatter
run: biome format --write
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
+13
View File
@@ -34,3 +34,16 @@ jobs:
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
- name: Sync cherry-pick back into canary
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git fetch origin canary main
git checkout -B canary origin/canary
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
git merge --abort
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
exit 1
fi
git push origin canary
+3 -3
View File
@@ -14,9 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"
@@ -0,0 +1,437 @@
# Upgrade Integration Test
#
# Tests that Dokploy can upgrade from version A to version B while keeping
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
#
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
# is paired with target_version. If target_version is empty, the highest tag
# available on Docker Hub is used as the target.
#
# Only triggered manually to avoid burning Actions minutes.
name: Upgrade Integration Test
on:
workflow_dispatch:
inputs:
floor_version:
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
required: false
default: 'v0.29.4'
target_version:
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
required: false
default: ''
env:
DOKPLOY_IMAGE: dokploy/dokploy
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
# ──────────────────────────────────────────────────────────────────────────────
jobs:
# ── 1. Build the matrix ─────────────────────────────────────────────────────
build-matrix:
name: Build upgrade pair matrix
runs-on: ubuntu-latest
outputs:
pairs: ${{ steps.pairs.outputs.pairs }}
steps:
- name: Generate pairs
id: pairs
env:
FLOOR: ${{ inputs.floor_version }}
TARGET: ${{ inputs.target_version }}
run: |
set -euo pipefail
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
ge() {
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
}
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
ALL_TAGS=$(curl -fsSL \
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
jq -r '.results[].name' | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
sort -V)
echo "All tags found:"
echo "$ALL_TAGS"
# Target version (version B): explicit input, else highest available.
if [ -n "$TARGET" ]; then
TO="$TARGET"
echo "Using user-supplied target version: $TO"
else
TO=$(echo "$ALL_TAGS" | tail -n1)
echo "No target supplied; using highest available: $TO"
fi
# Floor semver components
FLOOR_CLEAN="${FLOOR#v}"
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
# Each tag in [FLOOR, TO) → TO
PAIRS='[]'
while read -r TAG; do
[ -z "$TAG" ] && continue
# skip tags above-or-equal to the target (incl. the target itself)
ge "$TAG" "$TO" && continue
TAG_CLEAN="${TAG#v}"
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
PAIRS=$(echo "$PAIRS" | jq -c \
--arg f "$TAG" --arg t "$TO" \
'. + [{"from":$f,"to":$t}]')
fi
done <<< "$ALL_TAGS"
COUNT=$(echo "$PAIRS" | jq 'length')
echo "Total pairs: $COUNT"
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
upgrade-test:
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
needs: build-matrix
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
steps:
# ── Environment setup ──────────────────────────────────────────────────
- name: Free disk space
run: |
sudo rm -rf \
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
/usr/local/share/chromium /opt/hostedtoolcache
docker system prune -af --volumes
df -h
# ── Install Dokploy directly at VERSION A ──────────────────────────────
# install.sh:
# • requires root (run via sudo bash)
# • respects DOKPLOY_VERSION → installs that tag directly
# (so we don't need a separate "downgrade" step that would risk
# running B's migrations on A's expected schema)
# • respects ADVERTISE_ADDR → skip the external IP lookup
# • initializes Docker Swarm + dokploy-network itself
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
run: |
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
chmod +x /tmp/install.sh
sudo -E env \
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
ADVERTISE_ADDR="127.0.0.1" \
bash /tmp/install.sh
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
run: |
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
timeout 240 bash -c '
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
docker service ls
echo "✅ Service running on ${{ matrix.pair.from }}"
- name: Wait for Dokploy API to accept requests
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up"
# ── Bootstrap admin user ───────────────────────────────────────────────
- name: Register first admin user
id: auth
run: |
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
: > "$COOKIE_JAR"
chmod 600 "$COOKIE_JAR"
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
# better-auth: sign-up/email (allowed only before any owner exists)
set -x
curl -sS -i -X POST \
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
-H "Content-Type: application/json" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
| tee /tmp/signup.out
set +x
# Verify we got a session cookie
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
echo "⚠️ No session cookie matched expected name; jar contents:"
cat "$COOKIE_JAR"
fi
# ── Create test resources ──────────────────────────────────────────────
- name: Create project, databases and applications
id: create
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
run: |
set -euo pipefail
# --- tRPC POST helper ---
trpc_mut() {
curl -sf -X POST "$BASE/api/trpc/$1" \
-H "Content-Type: application/json" \
-b "$COOKIE" -c "$COOKIE" \
-d "{\"json\":$2}"
}
# --- Project ---
PROJECT=$(trpc_mut project.create \
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
echo "project.create → $PROJECT"
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
PROJECT_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
ENV_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.environment.environmentId // empty')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
echo "❌ Could not extract projectId from project.create response:"
echo "$PROJECT" | jq .
exit 1
fi
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
echo "❌ Could not extract environmentId — this version may not support environments."
echo " project.create response: $PROJECT"
exit 1
fi
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
# --- PostgreSQL 15 ---
PG=$(trpc_mut postgres.create \
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiPg1pass\",\
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
echo "postgres.create → $PG"
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
# deploy (not start) — deploy creates the Swarm service; start only
# scales an already-deployed service and 500s on a fresh resource.
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
# --- MongoDB 7.0 ---
MG=$(trpc_mut mongo.create \
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiMg1pass\",\
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
echo "mongo.create → $MG"
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
# --- Docker-image application helper ---
make_app() {
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
APP=$(trpc_mut application.create \
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
trpc_mut application.saveDockerProvider \
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
> /dev/null
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
> /dev/null
echo "$APP_ID"
}
# Static site (nginx)
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
# Node.js hello-world (echo server image — small, no args needed)
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
echo "✅ All resources created"
# ── Pre-upgrade health check ───────────────────────────────────────────
- name: Wait for all services → 'done' (pre-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
wait_done() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
echo "Waiting for $NAME to reach 'done'..."
timeout 360 bash -c "
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
-b '$COOKIE' | \
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
= 'done' ]; do
sleep 5
done
"
echo "✅ $NAME is done"
}
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
- name: Assert Docker Swarm services healthy (pre-upgrade)
run: |
echo "=== docker service ls ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy before upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All user services healthy before upgrade"
# ── Upgrade ────────────────────────────────────────────────────────────
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
run: |
docker service update \
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
--force \
"${{ env.DOKPLOY_SERVICE }}"
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
timeout 240 bash -c '
until ! docker service inspect dokploy \
--format "{{.UpdateStatus.State}}" 2>/dev/null \
| grep -q "^updating$"; do
sleep 4
done
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
echo "✅ Service running on ${{ matrix.pair.to }}"
- name: Wait for Dokploy API to respond post-upgrade
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up after upgrade"
# ── Post-upgrade health check ──────────────────────────────────────────
- name: Verify all services still healthy (post-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
check_status() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
-b "$COOKIE" | \
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
if [ "$STATUS" != "done" ]; then
echo "❌ $NAME status after upgrade: $STATUS"
return 1
fi
echo "✅ $NAME: $STATUS"
}
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
echo "=== docker service ls (post-upgrade) ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy after upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
# ── Diagnostics on failure ─────────────────────────────────────────────
- name: Dump state on failure
if: failure()
run: |
echo "=== docker service ls ===" && docker service ls || true
echo "=== dokploy service tasks ===" && \
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
echo "=== dokploy logs (last 200) ===" && \
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
echo "=== install.sh tail ===" && \
tail -100 /tmp/install.sh 2>&1 || true
echo "=== signup response ===" && \
cat /tmp/signup.out 2>&1 || true
echo "=== disk usage ===" && df -h
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
run: |
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
| Service | Image |
|---------|-------|
| ci-pg-db | postgres:15 |
| ci-mongo-db | mongo:7.0 |
| ci-static-app | nginx:alpine (static site) |
| ci-node-app | ealen/echo-server (Node.js hello-world) |
| ci-go-app | traefik/whoami (Go HTTP server) |
EOF
+2
View File
@@ -0,0 +1,2 @@
.env
.env.local
+6
View File
@@ -0,0 +1,6 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.
+9 -1
View File
@@ -120,12 +120,20 @@ pnpm run docker:push
## Password Reset
In the case you lost your password, you can reset it using the following command
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
```
Both commands print the new randomly generated password to the console.
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
```bash
+4 -1
View File
@@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -69,5 +69,8 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
ENTRYPOINT ["/usr/bin/tini", "--"]
# 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"]
+10
View File
@@ -6,3 +6,13 @@ npm run dev
```
open http://localhost:3000
```
## Inngest
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
```
npm run dev:inngest
```
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
+2 -1
View File
@@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
"build": "rimraf dist && tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
@@ -28,7 +29,7 @@
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^5.8.3"
"typescript": "^7.0.2"
},
"packageManager": "pnpm@10.22.0",
"engines": {
+2 -3
View File
@@ -2,13 +2,12 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
const revokeSessionSchema = z.object({
sessionId: z.string(),
});
describe("revokeSession input validation", () => {
it("accepts a valid session id", () => {
const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
expect(result.success).toBe(true);
});
it("rejects missing sessionId", () => {
const result = revokeSessionSchema.safeParse({});
expect(result.success).toBe(false);
});
it("rejects non-string sessionId", () => {
const result = revokeSessionSchema.safeParse({ sessionId: 123 });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,82 @@
import { spawnSync } from "node:child_process";
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
import { describe, expect, it } from "vitest";
const runCommand = (command: string) =>
spawnSync("bash", ["-c", command], {
encoding: "utf8",
});
const outputLines = (stdout: string) =>
stdout
.trim()
.split("\n")
.filter((line) => line.length > 0);
describe("createRestartSafeBackupCommand", () => {
it("restarts the service and preserves the backup error status", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("preserves the backup error status when the restart also fails", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"Service restart also failed with exit code 17",
]);
});
it("returns the restart error status when the backup succeeds", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(17);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("uploads only after a successful backup and service restart", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(0);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"upload",
]);
});
});
@@ -0,0 +1,123 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
const cmd = createCommand({
...base,
composePath: "./backend/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
expect(cmd).toContain("-f ./backend/docker-compose.yml");
});
});
describe("compose createCommand --env-file", () => {
it("points --env-file at the generated .env next to a nested compose file", () => {
const cmd = createCommand(
{
...base,
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file deploy/.env");
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
});
it("omits --env-file when createEnvFile is disabled", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
it("uses the code-root .env for raw sourceType", () => {
const cmd = createCommand(
{
...base,
sourceType: "raw",
composePath: "docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file .env");
});
it("does not add --env-file to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
});
@@ -33,6 +33,7 @@ const makeDomain = (serviceName: string) =>
https: false,
uniqueConfigKey: 1,
port: 3000,
enabled: true,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
@@ -0,0 +1,306 @@
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";
// With sourceType "raw", addDomainToCompose parses compose.composeFile
// directly instead of reading from disk, so baseCompose exposes it as a
// getter that always reflects the current composeYaml for each test case.
const baseComposeYaml = `
services:
frigate:
image: frigate
`;
let composeYaml = baseComposeYaml;
const baseCompose = {
appName: "test-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment: false,
randomize: false,
suffix: "",
get composeFile() {
return composeYaml;
},
} 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();
composeYaml = baseComposeYaml;
});
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.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
] as const)(
"removes stale labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
legacy:
image: frigate
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
[
"stack",
`services:
legacy:
image: frigate
deploy:
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
] as const)(
"removes stale labels from the previous %s service after reassignment",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, serviceName: "frigate", enabled: false },
]);
const previousService = result?.services?.legacy;
const labels =
composeType === "docker-compose"
? previousService?.labels
: previousService?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
] as const)(
"removes stale mapping labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({ "custom.label": "preserved" });
expect(
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.docker.network",
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.swarm.network",
],
] as const)(
"regenerates routing in mapping labels for an enabled domain in %s",
async (composeType, mappingComposeYaml, networkLabel) => {
composeYaml = mappingComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: true },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({
"custom.label": "preserved",
"traefik.enable": "true",
[networkLabel]: "dokploy-network",
"traefik.http.routers.test-app-1-web.rule":
"Host(`frigate.example.com`)",
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
});
},
);
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,59 @@
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
describe("raw remote compose conversion (#4794)", () => {
it("uses the saved raw source and preserves supported mount syntax", async () => {
vi.mocked(execAsyncRemote).mockResolvedValue({
stdout: "services:\n test:\n image: alpine:latest\n",
stderr: "",
});
const compose = {
appName: "raw-stack",
composeFile: `
services:
test:
image: alpine:latest
volumes:
- type: tmpfs
target: /scratch
- type: volume
source: test-data
target: /data
tmpfs:
- /cache
volumes:
test-data:
`,
composePath: "./docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
expect(converted?.services?.test?.volumes).toEqual([
{ type: "tmpfs", target: "/scratch" },
{
type: "volume",
source: "test-data",
target: "/data",
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,9 @@ const cases: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
@@ -46,9 +49,21 @@ const inputEncoding: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {
const hasDocker = () => {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => {
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
describe("GitHub app webhook preview deployments", () => {
const createApplication = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
applicationId: "application-id",
name: "my-app",
serverId: null,
previewLabels: [],
previewLimit: 3,
previewDeployments: [],
previewRequireCollaboratorPermissions: false,
...overrides,
});
const createPreviewDeployments = (total: number) =>
Array.from({ length: total }, (_, index) => ({
previewDeploymentId: `existing-preview-${index}`,
}));
const createPullRequestRequest = (action: string) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "pull_request",
},
body: {
installation: {
id: 12345,
},
action,
pull_request: {
id: 987,
number: 42,
title: "feat: add preview",
html_url: "https://github.com/agentHits/dokploy/pull/42",
labels: [],
user: {
login: "agentHits",
},
head: {
ref: "feature",
sha: "abc123",
},
base: {
ref: "main",
},
},
repository: {
name: "dokploy",
owner: {
login: "agentHits",
},
},
},
}) as unknown as NextApiRequest;
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.createPreviewDeployment.mockResolvedValue({
previewDeploymentId: "new-preview-id",
});
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
});
it("redeploys an existing preview even when the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(3),
}),
]);
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
previewDeploymentId: "existing-preview-0",
});
const res = createResponse();
await handler(createPullRequestRequest("synchronize"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "existing-preview-0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it("does not create a new preview once the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it("falls back to the default limit when none is configured", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: null,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: "application-id",
branch: "feature",
pullRequestId: 987,
pullRequestNumber: 42,
}),
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "new-preview-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
@@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
@@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should apply multiple negated watch paths as one pattern set", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
false,
);
});
it("should deploy when a changed file remains after exclusions", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
});
it("should combine positive and negated watch paths", () => {
const watchPaths = ["src/**", "!src/**/*.test.ts"];
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
@@ -0,0 +1,432 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const cfSuccess = (result: unknown) =>
jsonResponse({ success: true, errors: [], result });
const cfError = (message: string, status = 400) =>
jsonResponse(
{ success: false, errors: [{ code: 1, message }] },
false,
status,
);
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
beforeEach(() => {
mockFetch.mockReset();
});
describe("cloudflareClient.listZones", () => {
it("returns a single page of zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
const zones = await cloudflareClient.listZones(config);
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/zones?per_page=50&page=1");
});
it("paginates until a short page is returned", async () => {
const page = (count: number) =>
cfSuccess(
Array.from({ length: count }, (_, i) => ({
id: `z${i}`,
name: `zone${i}.com`,
})),
);
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
const zones = await cloudflareClient.listZones(config);
expect(zones).toHaveLength(53);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
});
});
describe("cloudflareClient.listRecords", () => {
it("lists records for a zone", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records).toEqual([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
});
});
describe("cloudflareClient MX priority", () => {
it("inlines the priority into the content when listing", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "mx-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
ttl: 300,
priority: 20,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.content).toBe("20 mail.example.com");
});
it("splits the priority back out when writing", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "20 mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 20,
});
});
it("falls back to priority 10 when the content has no leading number", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 10,
});
});
it("leaves non-MX content untouched", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "10 not-a-priority",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.content).toBe("10 not-a-priority");
expect(body.priority).toBeUndefined();
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBe(true);
});
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "hello",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
});
it("leaves the proxy status untouched when the caller does not set it", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
});
it("returns the proxy status when listing records", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "a-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
proxied: true,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.proxied).toBe(true);
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(updateUrl).toContain("/dns_records/existing-1");
expect(updateInit.method).toBe("PUT");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(1);
});
});
describe("cloudflareClient.updateRecord", () => {
it("PUTs directly to the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
expect(result).toEqual({ id: "r1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("cloudflareClient.deleteRecord", () => {
it("DELETEs the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({}));
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("DELETE");
});
});
describe("cloudflareClient.testConnection", () => {
it("succeeds when the token can list zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await expect(
cloudflareClient.testConnection(config),
).resolves.toBeUndefined();
});
it("surfaces Cloudflare's error message on an invalid token", async () => {
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
"Invalid API Token",
);
});
});
describe("cloudflareClient auth header", () => {
it("trims whitespace pasted into the token", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await cloudflareClient.testConnection({
providerType: "cloudflare",
apiToken: " cf-token\n",
});
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer cf-token",
);
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import {
DNS_SECRET_MASK,
maskDnsProviderConfig,
mergeDnsProviderConfig,
} from "@dokploy/server/services/dns-provider";
describe("maskDnsProviderConfig", () => {
it("masks the apiToken for a cloudflare config", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "real-token",
});
expect(masked).toEqual({
providerType: "cloudflare",
apiToken: DNS_SECRET_MASK,
});
});
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
const masked = maskDnsProviderConfig({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: "shh",
});
expect(masked).toEqual({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: DNS_SECRET_MASK,
});
});
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "",
});
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
});
});
describe("mergeDnsProviderConfig", () => {
it("restores the real secret when the incoming config still has the mask placeholder", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
});
it("keeps a freshly entered secret instead of the stored one", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "old-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: "new-token",
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
});
it("throws when switching provider type while the field is still masked", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA",
secretAccessKey: DNS_SECRET_MASK,
};
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
"Credentials must be re-entered",
);
});
it("does not require re-entry for fields that are not sensitive", () => {
const existing = {
providerType: "route53" as const,
accessKeyId: "AKIA_OLD",
secretAccessKey: "old-secret",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA_NEW",
secretAccessKey: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
providerType: "route53",
accessKeyId: "AKIA_NEW",
secretAccessKey: "old-secret",
});
});
});
+211
View File
@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const pbSuccess = (result: Record<string, unknown> = {}) =>
jsonResponse({ status: "SUCCESS", ...result });
const pbError = (message: string, status = 400) =>
jsonResponse({ status: "ERROR", message }, false, status);
const config = {
providerType: "porkbun" as const,
apiKey: "pk1_test",
secretApiKey: "sk1_test",
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("porkbunClient.listZones", () => {
it("lists all domains as zones", async () => {
mockFetch.mockResolvedValue(
pbSuccess({ domains: [{ domain: "example.com" }] }),
);
const zones = await porkbunClient.listZones(config);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/domain/listAll");
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({
apikey: "pk1_test",
secretapikey: "sk1_test",
});
});
});
describe("porkbunClient.listRecords", () => {
it("lists records for a domain", async () => {
mockFetch.mockResolvedValue(
pbSuccess({
records: [
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: "600",
prio: "0",
notes: "",
},
],
}),
);
const records = await porkbunClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
});
});
describe("porkbunClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
});
it("resolves the apex domain to an empty subdomain", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(600);
});
});
describe("porkbunClient.updateRecord", () => {
it("edits the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
const result = await porkbunClient.updateRecord(
config,
"example.com",
"1",
{
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
},
);
expect(result).toEqual({ id: "1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/dns/edit/example.com/1");
expect(JSON.parse(init.body as string)).toMatchObject({
name: "app",
type: "A",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("porkbunClient.deleteRecord", () => {
it("posts to the delete endpoint for the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await porkbunClient.deleteRecord(config, "example.com", "1");
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/dns/delete/example.com/1");
});
});
describe("porkbunClient.testConnection", () => {
it("succeeds when the credentials can ping the API", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
});
it("surfaces Porkbun's error message on invalid credentials", async () => {
mockFetch.mockResolvedValue(pbError("Invalid API key."));
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
"Invalid API key.",
);
});
});
+385
View File
@@ -0,0 +1,385 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: any };
const {
send,
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: any;
constructor(input: any) {
this.input = input;
}
}
const send = vi.fn();
class Route53Client {
send(command: unknown) {
return send(command);
}
}
return {
send,
Route53Client,
ListHostedZonesCommand: class extends FakeCommand {},
ListResourceRecordSetsCommand: class extends FakeCommand {},
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-route-53", () => ({
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
}));
import { route53Client } from "@dokploy/server/utils/dns/route53";
const config = {
providerType: "route53" as const,
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
});
describe("route53Client.listZones", () => {
it("strips the hostedzone prefix and the trailing dot", async () => {
send.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
});
it("follows the marker until IsTruncated is false", async () => {
send
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
IsTruncated: true,
NextMarker: "marker-1",
})
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([
{ id: "Z1", name: "a.com" },
{ id: "Z2", name: "b.com" },
]);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
});
});
describe("route53Client.listRecords", () => {
it("builds a type:name id and strips the trailing dot from the name", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([
{
id: "A:app.example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([]);
});
it("joins multiple values for the same record", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "example.com.",
Type: "NS",
TTL: 172800,
ResourceRecords: [
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
},
]);
});
it("keeps the values already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "3.3.3.3",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "1.1.1.1" },
{ Value: "2.2.2.2" },
{ Value: "3.3.3.3" },
]);
});
it("does not duplicate a value that is already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.1.1.1",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: "1.1.1.1" }]);
});
it("wraps unquoted TXT values in double quotes", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "TXT",
name: "example.com",
content: 'v=spf1 ~all\n"already quoted"',
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
});
});
describe("route53Client.updateRecord", () => {
it("only UPSERTs when the name/type identity did not change", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
});
expect(send).toHaveBeenCalledTimes(1);
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
it("deletes the old record set and creates the new one on rename", async () => {
const existingSet = {
Name: "old.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
};
send
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "new.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
},
]);
});
it("keeps every line of a multi-value record set", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
type: "NS",
name: "example.com",
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
});
const command = send.mock.calls[0]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
{ Value: "ns3.example.com" },
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
});
describe("route53Client.deleteRecord", () => {
it("deletes the record set found by name/type", async () => {
const existingSet = {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
await expect(
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
).rejects.toThrow("not found");
});
it("throws for a malformed record id", async () => {
await expect(
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
).rejects.toThrow("Invalid Route53 record id");
});
});
describe("route53Client.testConnection", () => {
it("resolves when ListHostedZones succeeds", async () => {
send.mockResolvedValueOnce({ HostedZones: [] });
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
});
it("propagates SDK errors", async () => {
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
await expect(route53Client.testConnection(config)).rejects.toThrow(
"InvalidClientTokenId",
);
});
});
+303
View File
@@ -20,6 +20,8 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
@@ -440,3 +442,304 @@ describe("doppler client", () => {
).rejects.toThrow("status 401");
});
});
describe("scaleway client", () => {
const config = {
providerType: "scaleway" as const,
region: "fr-par",
projectId: "project-1",
secretKey: "scw-secret-key",
apiUrl: "https://api.scaleway.com",
};
const accessResponse = (value: string) =>
jsonResponse({
secret_id: "secret-1",
revision: 1,
data: Buffer.from(value).toString("base64"),
});
it("reads a secret by name from the root path and decodes the payload", async () => {
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
const result = await scalewayClient.getSecrets(config, ["db-url"]);
expect(result).toEqual({ "db-url": "postgres://real" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
);
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
"scw-secret-key",
);
});
it("sends the folder of a path-qualified ref as secret_path", async () => {
mockFetch.mockResolvedValue(accessResponse("value"));
await scalewayClient.getSecrets(config, ["prod/db/password"]);
expect(mockFetch.mock.calls[0]?.[0]).toContain(
"secret_name=password&secret_path=%2Fprod%2Fdb",
);
});
it("extracts a field from a JSON secret and fetches the secret once", async () => {
mockFetch.mockResolvedValue(
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
);
const result = await scalewayClient.getSecrets(config, [
"db-creds:user",
"db-creds:port",
]);
expect(result).toEqual({
"db-creds:user": "admin",
"db-creds:port": "5432",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is not JSON but a field is requested", async () => {
mockFetch.mockResolvedValue(accessResponse("plain-value"));
await expect(
scalewayClient.getSecrets(config, ["db-creds:user"]),
).rejects.toThrow("is not JSON");
});
it("throws when the requested field is missing", async () => {
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
await expect(
scalewayClient.getSecrets(config, ["db-creds:password"]),
).rejects.toThrow('field "password" not found');
});
it("throws a clear error for a missing secret", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "not found" }, false, 404),
);
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found in path "/"',
);
});
it("reports authentication failures with the API message", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "denied authentication" }, false, 403),
);
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"authentication failed (status 403: denied authentication)",
);
});
it("throws when the secret has no enabled version", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"has no enabled version",
);
});
it("rejects a ref without a secret name", async () => {
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
"expected format <name> or <folder>/<name>[:field]",
);
});
it("tests the connection against the secrets listing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
await scalewayClient.testConnection(config);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
);
});
it("lists secret names with their folder prefix", async () => {
mockFetch.mockResolvedValue(
jsonResponse({
secrets: [
{ id: "1", name: "db-url", path: "/" },
{ id: "2", name: "password", path: "/prod/db" },
],
total_count: 2,
}),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toEqual(["db-url", "prod/db/password"]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("follows pagination until a short page is returned", async () => {
const page = (start: number, size: number) =>
jsonResponse({
secrets: Array.from({ length: size }, (_, index) => ({
id: String(start + index),
name: `secret-${start + index}`,
path: "/",
})),
});
mockFetch.mockImplementation(async (url: string) =>
url.includes("page=2") ? page(101, 2) : page(1, 100),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toHaveLength(102);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("resolves env refs end to end through a scaleway provider", async () => {
findMany.mockResolvedValue([
{
name: "scw-prod",
providerType: "scaleway",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(accessResponse("s3cret"));
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
describe("phase client", () => {
const config = {
providerType: "phase" as const,
token: "phase-rest-token",
appId: "app-123",
env: "Production",
path: "/",
apiUrl: "https://api.phase.dev",
};
it("fetches secrets by key and sends ServiceAccount auth", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "postgres://real", path: "/" },
{ key: "API_KEY", value: "key-123", path: "/" },
]),
);
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer ServiceAccount phase-rest-token",
);
});
it("throws when a requested secret is missing", async () => {
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
);
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
'secret "MISSING" not found in environment "Production"',
);
});
it("reports authentication failures with the status code", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ error: "unauthorized" }, false, 401),
);
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
"authentication failed (status 401: unauthorized)",
);
});
it("rejects apps without SSE enabled during testConnection", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: false,
}),
);
await expect(phaseClient.testConnection(config)).rejects.toThrow(
"enable Server-side Encryption (SSE)",
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
});
it("tests connection against the app then secrets listing", async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: true,
}),
)
.mockResolvedValueOnce(jsonResponse([]));
await phaseClient.testConnection(config);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
expect(mockFetch.mock.calls[1]?.[0]).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
});
it("lists secret names from the configured path", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "x", path: "/" },
{ key: "API_KEY", value: "y", path: "/" },
]),
);
const names = await phaseClient.listSecretNames?.(config);
expect(names).toEqual(["DB_URL", "API_KEY"]);
});
it("resolves env refs end to end through a phase provider", async () => {
findMany.mockResolvedValue([
{
name: "phase-prod",
providerType: "phase",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
);
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
const containers = [
{ containerId: "first-container" },
{ containerId: "selected-container" },
];
describe("resolveContainerSelection", () => {
it("selects the first container when no container is selected", () => {
expect(resolveContainerSelection(undefined, containers)).toBe(
"first-container",
);
});
it("preserves a manual selection when refreshed data contains it", () => {
const refreshedContainers = containers.map((container) => ({
...container,
}));
expect(
resolveContainerSelection("selected-container", refreshedContainers),
).toBe("selected-container");
});
it("falls back to the first container when the selection disappears", () => {
expect(resolveContainerSelection("removed-container", containers)).toBe(
"first-container",
);
});
it("keeps the current selection while container data is loading", () => {
expect(resolveContainerSelection("selected-container", undefined)).toBe(
"selected-container",
);
});
it("clears the selection when no containers are available", () => {
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
});
});
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied server.terminal", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
@@ -40,6 +40,7 @@ const ENTERPRISE_RESOURCES = [
"monitoring",
"auditLog",
"vaultProvider",
"dnsProvider",
];
describe("enterpriseOnlyResources set", () => {
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.server.terminal).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (role: string) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
accessedServers: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn = mockMemberData("deployer");
let rolesToReturn: { permission: string }[] = [];
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(true)),
}));
const { checkPermission, resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const withPermissions = (permissions: Record<string, string[]>) => {
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
};
beforeEach(() => {
vi.clearAllMocks();
memberToReturn = mockMemberData("deployer");
rolesToReturn = [];
});
describe("server.terminal on custom roles", () => {
it("a role with server.read alone cannot open a terminal", async () => {
withPermissions({ server: ["read"] });
await expect(
checkPermission(ctx, { server: ["read"] }),
).resolves.toBeUndefined();
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(true);
expect(perms.server.terminal).toBe(false);
});
it("a role with server.terminal can open a terminal", async () => {
withPermissions({ server: ["read", "terminal"] });
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
});
it("owner and admin keep terminal access", async () => {
for (const role of ["owner", "admin"]) {
memberToReturn = mockMemberData(role);
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
}
});
});
@@ -0,0 +1,24 @@
import { getSubnetCapacity } from "@dokploy/server/services/server-health";
import { describe, expect, test } from "vitest";
describe("getSubnetCapacity", () => {
test("returns null for missing/invalid input", () => {
expect(getSubnetCapacity(undefined)).toBeNull();
expect(getSubnetCapacity("")).toBeNull();
expect(getSubnetCapacity("10.0.0.0")).toBeNull();
expect(getSubnetCapacity("not-a-subnet")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/33")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull();
});
test("excludes network and broadcast addresses", () => {
expect(getSubnetCapacity("10.0.1.0/24")).toBe(254);
expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534);
expect(getSubnetCapacity("10.99.99.0/30")).toBe(2);
});
test("returns 0 for subnets too small to hold a host", () => {
expect(getSubnetCapacity("10.0.0.0/31")).toBe(0);
expect(getSubnetCapacity("10.0.0.0/32")).toBe(0);
});
});
@@ -0,0 +1,113 @@
import {
getBackupOverviewIcon,
getServiceOverviewIcon,
} from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
describe("getServiceOverviewIcon", () => {
test("returns a db icon for every known DB engine type", () => {
for (const type of [
"postgres",
"mariadb",
"mysql",
"mongo",
"redis",
"libsql",
] as const) {
expect(getServiceOverviewIcon({ type, icon: null })).toEqual({
kind: "db",
engine: type,
});
}
});
test("returns a custom icon for application/compose with a service icon set", () => {
expect(
getServiceOverviewIcon({
type: "application",
icon: "data:image/png;base64,x",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,x" });
expect(
getServiceOverviewIcon({
type: "compose",
icon: "data:image/png;base64,y",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,y" });
});
test("falls back to a generic icon for application/compose without a service icon", () => {
expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual(
{
kind: "generic",
type: "application",
},
);
expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({
kind: "generic",
type: "compose",
});
});
});
describe("getBackupOverviewIcon", () => {
test("returns a db icon for a database backup with a known databaseType", () => {
expect(
getBackupOverviewIcon({
databaseType: "postgres",
serviceType: null,
serviceOwnerType: "postgres",
}),
).toEqual({ kind: "db", engine: "postgres" });
});
test("returns a webServer icon for a web-server database backup", () => {
expect(
getBackupOverviewIcon({
databaseType: "web-server",
serviceType: null,
serviceOwnerType: "web-server",
}),
).toEqual({ kind: "webServer" });
});
test("returns a db icon for a compose-type backup dumping a known DB engine", () => {
expect(
getBackupOverviewIcon({
databaseType: "mysql",
serviceType: null,
serviceOwnerType: "compose",
}),
).toEqual({ kind: "db", engine: "mysql" });
});
test("returns a db icon for a volume backup of a known DB engine service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "mongo",
serviceOwnerType: "mongo",
}),
).toEqual({ kind: "db", engine: "mongo" });
});
test("returns a generic application icon for a volume backup of an application", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "application",
serviceOwnerType: "application",
}),
).toEqual({ kind: "generic", type: "application" });
});
test("returns a generic compose icon for a volume backup of a compose service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "compose",
serviceOwnerType: "compose",
}),
).toEqual({ kind: "generic", type: "compose" });
});
});
@@ -0,0 +1,79 @@
import type { OverviewDomain } from "@dokploy/server/services/overview";
import { sortOverviewDomains } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeDomain = (overrides: Partial<OverviewDomain>): OverviewDomain => ({
domainId: "id",
host: "example.com",
path: "/",
port: 3000,
customEntrypoint: null,
https: true,
certificateType: "letsencrypt",
createdAt: "2024-01-01T00:00:00.000Z",
enabled: true,
domainType: "application",
serviceOwnerId: "app",
serviceOwnerType: "application",
serviceName: "App",
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
...overrides,
});
describe("sortOverviewDomains", () => {
test("sorts by createdAt asc/desc", () => {
const domains = [
makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId),
).toEqual(["new", "mid", "old"]);
});
test("sorts by port asc/desc, with portless domains always last", () => {
const domains = [
makeDomain({ domainId: "none", port: null }),
makeDomain({ domainId: "low", port: 80 }),
makeDomain({ domainId: "high", port: 8080 }),
];
expect(
sortOverviewDomains(domains, "port-asc").map((d) => d.domainId),
).toEqual(["low", "high", "none"]);
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["high", "low", "none"]);
});
test("port sort with all domains portless is a no-op", () => {
const domains = [
makeDomain({ domainId: "a", port: null }),
makeDomain({ domainId: "b", port: null }),
];
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const domains = [
makeDomain({ domainId: "b", port: 8080 }),
makeDomain({ domainId: "a", port: 80 }),
];
const original = [...domains];
sortOverviewDomains(domains, "port-asc");
expect(domains).toEqual(original);
});
});
@@ -0,0 +1,106 @@
import type { OverviewService } from "@dokploy/server/services/overview";
import { sortOverviewServices } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeService = (overrides: Partial<OverviewService>): OverviewService => ({
id: "id",
type: "application",
name: "name",
appName: "app-name",
status: "running",
createdAt: "2024-01-01T00:00:00.000Z",
serverId: null,
serverName: null,
icon: null,
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
lastDeployAt: null,
...overrides,
});
describe("sortOverviewServices", () => {
test("sorts by name asc/desc", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
makeService({ id: "c", name: "Charlie" }),
];
expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual(
["a", "b", "c"],
);
expect(
sortOverviewServices(services, "name-desc").map((s) => s.id),
).toEqual(["c", "b", "a"]);
});
test("sorts by type asc/desc", () => {
const services = [
makeService({ id: "app", type: "application" }),
makeService({ id: "pg", type: "postgres" }),
makeService({ id: "comp", type: "compose" }),
];
expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual(
["app", "comp", "pg"],
);
expect(
sortOverviewServices(services, "type-desc").map((s) => s.id),
).toEqual(["pg", "comp", "app"]);
});
test("sorts by createdAt asc/desc", () => {
const services = [
makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "createdAt-asc").map((s) => s.id),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewServices(services, "createdAt-desc").map((s) => s.id),
).toEqual(["new", "mid", "old"]);
});
test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => {
const services = [
makeService({ id: "never", lastDeployAt: null }),
makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["new", "old", "never"]);
expect(
sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id),
).toEqual(["old", "new", "never"]);
});
test("lastDeploy sort with all services never deployed is a no-op", () => {
const services = [
makeService({ id: "a", lastDeployAt: null }),
makeService({ id: "b", lastDeployAt: null }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
];
const original = [...services];
sortOverviewServices(services, "name-asc");
expect(services).toEqual(original);
});
});
@@ -0,0 +1,202 @@
import { db } from "@dokploy/server/db";
import { updateSecurityById } from "@dokploy/server/services/security";
import { beforeEach, describe, expect, it, vi } from "vitest";
const OLD_ROW = {
securityId: "sec-1",
username: "alice",
password: "oldpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const NEW_ROW = {
securityId: "sec-1",
username: "bob",
password: "newpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const APP = {
applicationId: "app-1",
appName: "myapp",
serverId: null,
};
const mocks = vi.hoisted(() => {
const returningMock = vi.fn();
const chain = {
set: vi.fn(),
where: vi.fn(),
returning: returningMock,
};
chain.set.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
const tx = {
update: vi.fn(),
};
tx.update.mockReturnValue(chain);
return {
returningMock,
chain,
tx,
transaction: vi.fn(),
findFirst: vi.fn(),
findApplicationById: vi.fn(),
createSecurityMiddleware: vi.fn(),
removeSecurityMiddleware: vi.fn(),
replaceSecurityMiddlewareUser: vi.fn(),
};
});
vi.mock("@dokploy/server/db", () => ({
db: {
transaction: mocks.transaction,
query: {
security: {
findFirst: mocks.findFirst,
},
},
},
}));
vi.mock("@dokploy/server/services/application", () => ({
findApplicationById: mocks.findApplicationById,
}));
vi.mock("@dokploy/server/utils/traefik/security", () => ({
createSecurityMiddleware: mocks.createSecurityMiddleware,
removeSecurityMiddleware: mocks.removeSecurityMiddleware,
replaceSecurityMiddlewareUser: mocks.replaceSecurityMiddlewareUser,
}));
describe("updateSecurityById", () => {
beforeEach(() => {
mocks.findFirst.mockReset();
mocks.findApplicationById.mockReset();
mocks.returningMock.mockReset();
mocks.replaceSecurityMiddlewareUser.mockReset();
mocks.removeSecurityMiddleware.mockReset();
mocks.createSecurityMiddleware.mockReset();
mocks.transaction.mockReset();
mocks.tx.update.mockReset();
mocks.chain.set.mockReset();
mocks.chain.where.mockReset();
mocks.chain.set.mockReturnValue(mocks.chain);
mocks.chain.where.mockReturnValue(mocks.chain);
mocks.tx.update.mockReturnValue(mocks.chain);
mocks.transaction.mockImplementation(
async (cb: (tx: typeof mocks.tx) => unknown) => cb(mocks.tx),
);
mocks.findFirst.mockResolvedValue(OLD_ROW);
mocks.findApplicationById.mockResolvedValue(APP);
mocks.returningMock.mockResolvedValue([NEW_ROW]);
mocks.replaceSecurityMiddlewareUser.mockResolvedValue(undefined);
mocks.removeSecurityMiddleware.mockResolvedValue(undefined);
mocks.createSecurityMiddleware.mockResolvedValue(undefined);
});
it("performs a single middleware write after the DB transaction commits with old + new security", async () => {
await updateSecurityById("sec-1", { username: "bob", password: "newpass" });
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.findFirst).toHaveBeenCalledTimes(1);
expect(mocks.findApplicationById).toHaveBeenCalledWith("app-1");
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledWith(
APP,
OLD_ROW,
NEW_ROW,
);
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
});
it("does NOT touch the middleware file when tx.update throws (unique violation rollback)", async () => {
mocks.returningMock.mockRejectedValue(
new Error(
'duplicate key value violates unique constraint "security_username_applicationId_unique"',
),
);
await expect(
updateSecurityById("sec-1", { username: "bob" }),
).rejects.toThrow();
expect(mocks.replaceSecurityMiddlewareUser).not.toHaveBeenCalled();
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
});
it("rethrows a TRPCError BAD_REQUEST wrapping the underlying error on tx.update failure", async () => {
mocks.returningMock.mockRejectedValue(
new Error(
'duplicate key value violates unique constraint "security_username_applicationId_unique"',
),
);
await expect(
updateSecurityById("sec-1", { username: "bob" }),
).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("does NOT touch the middleware file when the security row is missing", async () => {
mocks.findFirst.mockResolvedValue(undefined);
await expect(updateSecurityById("missing", {})).rejects.toThrow();
expect(mocks.replaceSecurityMiddlewareUser).not.toHaveBeenCalled();
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.tx.update).not.toHaveBeenCalled();
});
it("commits the DB row even if the post-commit middleware write fails (stale-credential, not lockout)", async () => {
mocks.returningMock.mockResolvedValue([NEW_ROW]);
mocks.replaceSecurityMiddlewareUser.mockRejectedValue(
new Error("ssh failure"),
);
await expect(
updateSecurityById("sec-1", { username: "bob", password: "newpass" }),
).rejects.toThrow();
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
});
it("runs the middleware write only after the transaction callback returns (post-commit)", async () => {
let txReturned = false;
mocks.transaction.mockImplementation(
async (cb: (tx: typeof mocks.tx) => unknown) => {
await cb(mocks.tx);
txReturned = true;
return { oldSecurity: OLD_ROW, newSecurity: NEW_ROW, application: APP };
},
);
mocks.replaceSecurityMiddlewareUser.mockImplementation(async () => {
expect(txReturned).toBe(true);
});
await updateSecurityById("sec-1", { username: "bob" });
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
});
});
describe("updateSecurityById db mock sanity", () => {
it("exposes the mocked db used by the service under test", () => {
expect((db as unknown as { transaction: unknown }).transaction).toBe(
mocks.transaction,
);
});
});
@@ -0,0 +1,261 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
// Swarm keeps converging a service for a bit after it's created (scheduling
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
// setupMonitoring again before that settles races that internal bump, so wait
// for two consecutive reads to agree before treating the service as stable.
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
const deadline = Date.now() + timeoutMs;
let lastIndex: string | null = null;
while (Date.now() < deadline) {
const inspect = await docker.getService(name).inspect();
if (inspect.Version.Index === lastIndex) return;
lastIndex = inspect.Version.Index;
await new Promise((resolve) => setTimeout(resolve, 50));
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
const hasDocker = () => {
try {
execSync("docker info", { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await waitForServiceConvergence(SERVICE_NAME);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("forwardAuthMiddlewareName", () => {
@@ -0,0 +1,71 @@
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
compose: {
findMany: mocks.findMany,
},
},
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
describe("reconnectServicesToTraefik", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findMany.mockResolvedValue([]);
});
it("does not execute an empty local command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("does not execute an empty remote command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments to the local Traefik network", async () => {
mocks.findMany.mockResolvedValue([
{ appName: "first-compose" },
{ appName: "second-compose" },
]);
await reconnectServicesToTraefik();
expect(mocks.execAsync).toHaveBeenCalledOnce();
expect(mocks.execAsync).toHaveBeenCalledWith(
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments on a remote server", async () => {
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,306 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import type { Security } from "@dokploy/server/services/security";
import type { FileConfig } from "@dokploy/server/utils/traefik/file-types";
import { replaceSecurityMiddlewareUser } from "@dokploy/server/utils/traefik/security";
import * as bcrypt from "bcrypt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parse, stringify } from "yaml";
const remoteMocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
writeFileRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: remoteMocks.execAsyncRemote,
writeFileRemote: remoteMocks.writeFileRemote,
};
});
const OLD: Security = {
securityId: "sec-1",
username: "alice",
password: "oldpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const initialMiddlewares = (users: string[]): FileConfig => ({
http: {
middlewares: {
"auth-myapp": {
basicAuth: { removeHeader: true, users },
},
},
},
});
const initialAppConfig = (): FileConfig => ({
http: {
routers: {
"myapp-router-1": {
rule: "Host(`x`)",
service: "myapp-service",
middlewares: ["auth-myapp"],
},
},
services: {
"myapp-service": {
loadBalancer: {
servers: [{ url: "http://myapp:3000" }],
passHostHeader: true,
},
},
},
},
});
type BasicAuthMW = {
basicAuth?: { removeHeader?: boolean; users?: string[] };
};
const hashOf = (line: string): string => line.split(":")[1] ?? "";
const readUsers = (file: string): string[] => {
const parsed = parse(fs.readFileSync(file, "utf8")) as FileConfig;
const mw = parsed.http?.middlewares?.["auth-myapp"] as
| BasicAuthMW
| undefined;
return mw?.basicAuth?.users ?? [];
};
describe("replaceSecurityMiddlewareUser (local filesystem)", () => {
let cwd: string;
let dynamicPath: string;
let middlewaresPath: string;
let appConfigPath: string;
const APP_LOCAL = {
applicationId: "app-1",
appName: "myapp",
serverId: null,
} as unknown as ApplicationNested;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-security-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
middlewaresPath = path.join(dynamicPath, "middlewares.yml");
appConfigPath = path.join(dynamicPath, "myapp.yml");
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
it("replaces the old username's hash with the new one in a single write (rename)", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash", "carol:carolhash"])),
"utf8",
);
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
const [name, hash = ""] = bobLine!.split(":");
expect(name).toBe("bob");
expect(await bcrypt.compare("newpass", hash)).toBe(true);
expect(await bcrypt.compare("oldpass", hash)).toBe(false);
const appCfg = parse(fs.readFileSync(appConfigPath, "utf8")) as FileConfig;
expect(appCfg.http?.routers?.["myapp-router-1"]?.middlewares).toContain(
"auth-myapp",
);
});
it("replaces only the hash when the username is unchanged (password rotation)", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash", "carol:carolhash"])),
"utf8",
);
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "alice",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const aliceLine = users.find((u) => u.startsWith("alice:"));
expect(aliceLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(aliceLine!))).toBe(true);
expect(await bcrypt.compare("oldpass", hashOf(aliceLine!))).toBe(false);
expect(users).toHaveLength(2);
});
it("creates the basicAuth middleware entry if it does not yet exist", async () => {
const otherMiddlewares: FileConfig = {
http: {
middlewares: {
"redirect-other": { redirectScheme: { scheme: "https" } },
},
},
};
fs.writeFileSync(middlewaresPath, stringify(otherMiddlewares), "utf8");
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const parsed = parse(
fs.readFileSync(middlewaresPath, "utf8"),
) as FileConfig;
expect(parsed.http?.middlewares?.["redirect-other"]).toBeDefined();
const users = readUsers(middlewaresPath);
expect(users).toHaveLength(1);
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(bobLine!))).toBe(true);
});
it("attaches the middleware to routers that did not reference it yet", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash"])),
"utf8",
);
const appConfigNoMiddleware: FileConfig = {
http: {
routers: {
"myapp-router-1": {
rule: "Host(`x`)",
service: "myapp-service",
},
},
services: {
"myapp-service": {
loadBalancer: {
servers: [{ url: "http://myapp:3000" }],
passHostHeader: true,
},
},
},
},
};
fs.writeFileSync(appConfigPath, stringify(appConfigNoMiddleware), "utf8");
const newSecurity: Security = {
...OLD,
username: "alice",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const appCfg = parse(fs.readFileSync(appConfigPath, "utf8")) as FileConfig;
expect(appCfg.http?.routers?.["myapp-router-1"]?.middlewares).toContain(
"auth-myapp",
);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(await bcrypt.compare("newpass", hashOf(users[0]!))).toBe(true);
});
});
describe("replaceSecurityMiddlewareUser (remote serverId)", () => {
const APP_REMOTE = {
applicationId: "app-1",
appName: "myapp",
serverId: "server-1",
} as unknown as ApplicationNested;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("writes the remote middlewares.yml exactly once with the old user replaced", async () => {
remoteMocks.execAsyncRemote.mockImplementation(
async (_serverId, command) => {
if (command.includes("middlewares.yml")) {
return {
stdout: stringify(
initialMiddlewares(["alice:oldhash", "carol:carolhash"]),
),
stderr: "",
};
}
if (command.includes("myapp.yml")) {
return { stdout: stringify(initialAppConfig()), stderr: "" };
}
return { stdout: "", stderr: "" };
},
);
remoteMocks.writeFileRemote.mockResolvedValue(undefined);
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_REMOTE, OLD, newSecurity);
const mwWrites = remoteMocks.writeFileRemote.mock.calls.filter(([, p]) =>
(p as string).includes("middlewares.yml"),
);
expect(mwWrites).toHaveLength(1);
const [, , content] = mwWrites[0]!;
const written = parse(content as string) as FileConfig;
const mw = written.http?.middlewares?.["auth-myapp"] as
| BasicAuthMW
| undefined;
const users = mw?.basicAuth?.users ?? [];
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(bobLine!))).toBe(true);
const appWrites = remoteMocks.writeFileRemote.mock.calls.filter(([, p]) =>
(p as string).includes("myapp.yml"),
);
expect(appWrites).toHaveLength(1);
expect(remoteMocks.execAsyncRemote).toHaveBeenCalledTimes(2);
});
});
@@ -151,6 +151,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const baseRedirect: Redirect = {
@@ -0,0 +1,121 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
writeFileRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
writeFileRemote: mocks.writeFileRemote,
};
});
describe("writeAppTraefikConfig", () => {
let cwd: string;
let dynamicPath: string;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
// Regression test for #5189: Traefik's file provider rejects a standalone
// `routers: {}` / `services: {}` map and aborts its watcher for every
// dynamic config once it hits one, so an app with no domains must never
// get an on-disk config file at all.
it("removes the file instead of writing empty routers/services", async () => {
const appName = "no-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
fs.writeFileSync(configPath, "stale content", "utf8");
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
appName,
);
expect(fs.existsSync(configPath)).toBe(false);
});
it("writes the file when routers/services are present", async () => {
const appName = "with-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
await writeAppTraefikConfig(
{
http: {
routers: {
[`${appName}-router-1`]: {
rule: "Host(`x`)",
service: `${appName}-service`,
},
},
services: {},
},
},
appName,
);
expect(fs.existsSync(configPath)).toBe(true);
});
it("removes the remote file instead of writing empty routers/services", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
"no-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^rm -f /);
expect(command).toContain("no-domain-app.yml");
});
it("writes the remote file when routers/services are present", async () => {
mocks.writeFileRemote.mockResolvedValue(undefined);
await writeAppTraefikConfig(
{
http: {
routers: {
"with-domain-app-router-1": {
rule: "Host(`x`)",
service: "with-domain-app-service",
},
},
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
const [serverId, remotePath, content] =
mocks.writeFileRemote.mock.calls[0] ?? [];
expect(serverId).toBe("server-id");
expect(remotePath).toContain("with-domain-app.yml");
expect(content).toContain("with-domain-app-router-1");
});
});
+26 -1
View File
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
});
it("gates a remote server terminal on server access", async () => {
mockHasPermission.mockResolvedValue(true);
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
// the remote path must never fall through to the owner/admin local branch
expect(mockFindMember).not.toHaveBeenCalled();
});
it("denies a remote server terminal without the server.terminal permission", async () => {
// Reaching a server (to deploy on it) must not imply a root shell on it.
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(false);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
expect(mockHasPermission).toHaveBeenCalledWith(
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
{ server: ["terminal"] },
);
});
it("allows a remote server terminal with the server.terminal permission", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("does not check permissions for a server the caller cannot access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
expect(mockHasPermission).not.toHaveBeenCalled();
});
});
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
To use a cluster feature, you need to configure at least
a registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-foreground"
>
Settings
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$${1}" {...field} />
<Input placeholder="http://mydomain/$1" {...field} />
</FormControl>
<FormMessage />
@@ -148,7 +148,7 @@ export const ShowDeployment = ({
<DialogDescription className="flex items-center gap-2">
<span className="flex items-center gap-2">
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
<Badge variant="blank" className="text-xs tabular-nums">
{filteredLogs.length} lines
</Badge>
</span>
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}
@@ -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,
@@ -249,6 +254,42 @@ 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnable(domain.domainId)}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.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>
);
},
},
{
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");
}
@@ -166,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
try {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
serverId: application?.serverId ?? undefined,
});
setValidationStates((prev) => ({
@@ -200,7 +224,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 +291,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 +444,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 +500,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 +515,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 */}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => {
name="environment"
render={({ field }) => (
<FormItem>
<div className="flex justify-end">
<VaultImportDialog
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("environment", next, {
shouldDirty: true,
})
}
/>
</div>
<FormControl className="">
<CodeEditor
style={
@@ -151,6 +151,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -1,4 +1,3 @@
import DOMPurify from "dompurify";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
import { sanitizeSvg } from "@/utils/sanitize-svg";
interface ShowIconSettingsProps {
serviceId: string;
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
@@ -1,6 +1,7 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
},
);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -50,6 +50,7 @@ interface Props {
id: string;
type: "application" | "compose";
serverId?: string;
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z.object({
@@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({
}),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
export const RestoreVolumeBackups = ({
id,
type,
serverId,
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -1,4 +1,11 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -54,6 +55,7 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "swarm",
refetchInterval: 5000,
},
);
@@ -66,22 +68,18 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "native",
refetchInterval: 5000,
},
);
const containers = data?.filter((container) => container.containerId);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -106,6 +104,7 @@ export const ShowDockerLogsStack = ({
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
},
{
enabled: !!appName,
refetchInterval: 5000,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, data),
);
}, [data]);
return (
@@ -74,6 +74,7 @@ interface Props {
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z
@@ -200,6 +201,7 @@ export const RestoreBackup = ({
databaseType,
serverId,
backupType = "database",
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -311,10 +313,12 @@ export const RestoreBackup = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
@@ -14,10 +14,11 @@ import {
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
CircuitBoard,
ExternalLink,
GlobeIcon,
Loader2,
Rocket,
Server,
@@ -71,24 +72,26 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Application" as const,
name: app.name,
icon: app.icon,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
environmentName: app.environment.name,
serviceId: app.applicationId,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
type: "Compose" as const,
name: comp.name,
icon: comp.icon,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
environmentName: comp.environment.name,
serviceId: comp.composeId,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`,
};
}
return null;
@@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
if (!info) return <span className="text-muted-foreground">—</span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
{info.icon ? (
<img
src={info.icon}
alt={info.name}
className="size-4 object-contain shrink-0"
/>
) : info.type === "Application" ? (
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
@@ -0,0 +1,428 @@
"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,
Boxes,
Database,
Gauge,
HardDrive,
Layers,
Loader2,
type LucideIcon,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
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 { 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 DiskUsageItem =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getDiskUsage"][number];
type BuildCacheBase =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getBuildCache"][number];
type BuildCacheRow = BuildCacheBase & { key: string };
interface Props {
serverId?: string;
}
const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [
{ type: "Images", title: "Images", icon: Layers },
{ type: "Containers", title: "Containers", icon: Boxes },
{ type: "Local Volumes", title: "Volumes", icon: HardDrive },
{ type: "Build Cache", title: "Build Cache", icon: Database },
];
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 StatCard = ({
icon: Icon,
title,
item,
isLoading,
}: {
icon: LucideIcon;
title: string;
item?: DiskUsageItem;
isLoading: boolean;
}) => (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="size-4" />
{title}
</div>
{isLoading ? (
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<>
<div className="mt-2 text-2xl font-semibold">{item?.size ?? "-"}</div>
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>{item?.totalCount ?? 0} total</span>
<span>{item?.active ?? 0} active</span>
<span>{item?.reclaimable ?? "-"} reclaimable</span>
</div>
</>
)}
</Card>
);
export const ShowDiskUsage = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Size", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: diskUsage, isLoading: isLoadingDiskUsage } =
api.dockerDiskUsage.getDiskUsage.useQuery({ serverId });
const { data: buildCache, isLoading: isLoadingBuildCache } =
api.dockerDiskUsage.getBuildCache.useQuery({ serverId });
const { mutateAsync: pruneBuildCache, isPending: isPruning } =
api.dockerDiskUsage.pruneBuildCache.useMutation();
const usageByType = useMemo(
() => new Map((diskUsage ?? []).map((item) => [item.type, item])),
[diskUsage],
);
const rows = useMemo<BuildCacheRow[]>(
() =>
(buildCache ?? []).map((entry) => ({
...entry,
key: entry.id,
})),
[buildCache],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(entry) =>
entry.id.toLowerCase().includes(query) ||
entry.description.toLowerCase().includes(query) ||
entry.type.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handlePrune = async () => {
try {
await pruneBuildCache({ serverId });
toast.success("Build cache pruned");
await Promise.all([
utils.dockerDiskUsage.getBuildCache.invalidate(),
utils.dockerDiskUsage.getDiskUsage.invalidate(),
]);
} catch (error) {
toast.error("Error pruning build cache", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const columns = useMemo<ColumnDef<BuildCacheRow>[]>(
() => [
{
accessorKey: "id",
header: ({ column }) => (
<SortableHeader column={column} title="Cache ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 16)}
</span>
),
},
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
},
{
accessorKey: "description",
header: "Description",
enableSorting: false,
cell: ({ row }) => (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={row.original.description}
>
{row.original.description || "-"}
</div>
),
},
{
id: "Size",
accessorFn: (entry) => entry.sizeBytes,
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.size}</span>
),
},
{
id: "created",
accessorKey: "createdSince",
header: "Created",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.createdSince}
</span>
),
},
{
id: "lastUsed",
accessorKey: "lastUsedSince",
header: "Last Used",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.lastUsedSince}
</span>
),
},
{
accessorKey: "usageCount",
header: ({ column }) => (
<SortableHeader column={column} title="Usage" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.usageCount}
</span>
),
},
{
id: "flags",
enableSorting: false,
header: "Flags",
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.inUse && <Badge variant="blue">In use</Badge>}
{row.original.shared && <Badge variant="outline">Shared</Badge>}
</div>
),
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{STAT_CARDS.map((stat) => (
<StatCard
key={stat.type}
icon={stat.icon}
title={stat.title}
item={usageByType.get(stat.type)}
isLoading={isLoadingDiskUsage}
/>
))}
</div>
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Gauge className="size-6 text-muted-foreground self-center" />
Build Cache
</CardTitle>
<CardDescription>
Cache layers kept by the Docker builder on the selected
server.
</CardDescription>
</div>
<DialogAction
title="Prune build cache"
description="This will remove all build cache entries that are not currently in use. This action cannot be undone."
onClick={handlePrune}
disabled={isPruning}
>
<Button variant="outline" size="sm" disabled={isPruning}>
<Trash2 className="size-4 mr-1" />
Prune build cache
</Button>
</DialogAction>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoadingBuildCache ? (
<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>
) : !buildCache?.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">
<Database className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No build cache found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Layers cached by "docker build" on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by id, type or description..."
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 build cache entries 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>
);
};
@@ -0,0 +1,425 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
interface Props {
serverId?: string;
}
type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number];
type EventRow = DockerEvent & { key: string };
const RANGE_OPTIONS = [
{ label: "Last 5 minutes", value: 5 },
{ label: "Last 15 minutes", value: 15 },
{ label: "Last hour", value: 60 },
{ label: "Last 6 hours", value: 360 },
{ label: "Last 24 hours", value: 1440 },
];
type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank";
const TYPE_VARIANTS: Record<string, BadgeVariant> = {
container: "blue",
image: "green",
volume: "yellow",
network: "orange",
service: "blue",
node: "orange",
};
const ACTION_VARIANTS: Record<string, BadgeVariant> = {
create: "green",
start: "green",
pull: "green",
connect: "green",
die: "red",
destroy: "red",
kill: "red",
stop: "red",
remove: "red",
disconnect: "red",
pause: "yellow",
unpause: "yellow",
};
const getTypeVariant = (type?: string): BadgeVariant =>
(type && TYPE_VARIANTS[type]) || "blank";
const getActionVariant = (action?: string): BadgeVariant =>
(action && ACTION_VARIANTS[action]) || "blank";
const getResource = (event: DockerEvent) =>
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
const getAttributesText = (event: DockerEvent) => {
const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter(
([key]) => key !== "name",
);
return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-";
};
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>
);
export const ShowDockerEvents = ({ serverId }: Props) => {
const [minutes, setMinutes] = useState(15);
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ id: "time", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 15,
});
const { data, isLoading, isRefetching, refetch, error } =
api.docker.getEvents.useQuery({
serverId,
minutes,
});
const events = useMemo<EventRow[]>(
() =>
(data?.events ?? []).map((event, index) => ({
...event,
key: `${event.time}-${event.Action}-${index}`,
})),
[data],
);
const filteredEvents = useMemo(() => {
if (!search.trim()) return events;
const query = search.toLowerCase();
return events.filter((event) => {
return (
event.Type?.toLowerCase().includes(query) ||
event.Action?.toLowerCase().includes(query) ||
event.Actor?.Attributes?.name?.toLowerCase().includes(query) ||
event.Actor?.ID?.toLowerCase().includes(query)
);
});
}, [events, search]);
const columns = useMemo<ColumnDef<EventRow>[]>(
() => [
{
id: "time",
accessorFn: (event) => event.time ?? 0,
header: ({ column }) => <SortableHeader column={column} title="Time" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.time
? new Date(row.original.time * 1000).toLocaleTimeString()
: "-"}
</span>
),
},
{
id: "type",
accessorFn: (event) => event.Type ?? "",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant={getTypeVariant(row.original.Type)}>
{row.original.Type ?? "unknown"}
</Badge>
),
},
{
id: "action",
accessorFn: (event) => event.Action ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Action" />
),
cell: ({ row }) => (
<Badge variant={getActionVariant(row.original.Action)}>
{row.original.Action ?? "-"}
</Badge>
),
},
{
id: "resource",
accessorFn: (event) => getResource(event),
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
cell: ({ row }) => {
const resource = getResource(row.original);
return (
<div
className="max-w-[220px] truncate font-mono text-xs"
title={resource}
>
{resource}
</div>
);
},
},
{
id: "attributes",
accessorFn: (event) => getAttributesText(event),
header: "Attributes",
enableSorting: false,
cell: ({ row }) => {
const attributesText = getAttributesText(row.original);
return (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={attributesText}
>
{attributesText}
</div>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredEvents,
columns,
getRowId: (row) => row.key,
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>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Activity className="size-6 text-muted-foreground self-center" />
Docker Events
</CardTitle>
<CardDescription>
Events reported by the Docker daemon, equivalent to running
"docker events".
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-4 mr-1 ${isRefetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{error && (
<p className="text-sm text-destructive">{error.message}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by type, action or name..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
className="max-w-xs"
/>
<Select
value={String(minutes)}
onValueChange={(value) => setMinutes(Number(value))}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
<div className="flex flex-row items-center justify-center gap-2">
<span>Loading events...</span>
<Loader2 className="size-4 animate-spin" />
</div>
</TableCell>
</TableRow>
) : 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 events found in the selected time range.
</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 items-center gap-2">
<span className="text-sm text-muted-foreground">Go to</span>
<Input
type="number"
min={1}
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
key={table.getState().pagination.pageIndex}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
const value = Number(
(e.target as HTMLInputElement).value,
);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
onBlur={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
className="w-16 h-8"
/>
</div>
<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>
);
};
@@ -0,0 +1,454 @@
import {
AlertTriangle,
Cpu,
Download,
HardDrive,
Loader2,
Network,
RefreshCw,
Server as ServerIcon,
} from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2);
const pct = (used: number, total: number) =>
Math.min(100, Math.round((used / (total || 1)) * 100));
const SINCE_HOURS_OPTIONS = [
{ label: "Last hour", value: 1 },
{ label: "Last 6 hours", value: 6 },
{ label: "Last 24 hours", value: 24 },
{ label: "Last 7 days", value: 168 },
];
export const ShowHealth = ({ serverId }: Props) => {
const [sinceHours, setSinceHours] = useState(24);
const {
data: health,
isFetching,
refetch,
isFetched,
} = api.docker.getServerHealth.useQuery(
{ serverId, sinceHours },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const memUsedPct = health
? pct(health.resources.memUsedBytes, health.resources.memTotalBytes)
: 0;
const diskUsedPct = health
? pct(health.disk.usedBytes, health.disk.totalBytes)
: 0;
const inotifyUsedPct = health
? pct(health.inotify.currentInstances, health.inotify.maxInstances)
: 0;
const daemonLogsWindowText = health?.daemonLogsWindow
? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}`
: null;
const daemonErrorsText = [
daemonLogsWindowText,
health && health.daemonErrors.length > 0
? health.daemonErrors.join("\n")
: "(no daemon errors matched in this window)",
]
.filter(Boolean)
.join("\n\n");
const buildHealthReport = () => {
if (!health) return "";
const lines: string[] = [];
lines.push("# Dokploy server health report");
lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`);
lines.push(
`Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`,
);
lines.push("");
lines.push("## Containers & services");
lines.push(`Containers: ${health.containers.containerCount}`);
lines.push(`Swarm services: ${health.containers.serviceCount}`);
lines.push("");
lines.push("## Host resources");
lines.push(
`Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`,
);
lines.push(`CPU cores: ${health.resources.cpuCount}`);
lines.push("");
lines.push("## Disk (/)");
lines.push(
`${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`,
);
lines.push("");
lines.push("## Inotify");
lines.push(
`max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`,
);
lines.push(`max_user_watches: ${health.inotify.maxWatches}`);
lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`);
lines.push(
`Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`,
);
lines.push("");
lines.push("## Docker networks");
lines.push(`Total networks: ${health.dockerNetworks.count}`);
if (health.dockerNetworks.addressPools) {
lines.push(
`default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`,
);
}
if (health.dockerNetworks.usageError) {
lines.push(
`Could not read network IP usage: ${health.dockerNetworks.usageError}`,
);
} else if (health.dockerNetworks.usage.length > 0) {
lines.push("");
lines.push(
"| Network | Driver | Subnet | IPs in use | Capacity | Usage |",
);
lines.push("|---|---|---|---|---|---|");
for (const n of health.dockerNetworks.usage) {
lines.push(
`| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`,
);
}
}
lines.push("");
if (health.reservation) {
lines.push("## Memory/CPU reservation");
lines.push(
`Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`,
);
lines.push(
`Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`,
);
lines.push(`Across ${health.reservation.appCount} Application services`);
if (health.reservation.unsupportedComposeCount > 0) {
lines.push(
`${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`,
);
}
lines.push("");
}
lines.push("## Docker daemon errors (raw)");
lines.push("```");
lines.push(daemonErrorsText);
lines.push("```");
return lines.join("\n");
};
const handleDownload = () => {
const report = buildHealthReport();
if (!report) return;
const blob = new Blob([report], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col gap-4 w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-medium">Server diagnostics</h3>
<p className="text-sm text-muted-foreground max-w-xl">
Runs a read-only check over SSH (inotify limits, disk, Docker
network pool, daemon errors). Runs automatically when you open
this tab — click Re-check to refresh.
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={String(sinceHours)}
onValueChange={(v) => setSinceHours(Number(v))}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_HOURS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
{isFetched ? "Re-check" : "Check server health"}
</Button>
{health && !health.error && (
<Button variant="outline" onClick={handleDownload}>
<Download className="size-4 mr-2" />
Download report
</Button>
)}
</div>
</div>
{isFetching && !health && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground border border-dashed rounded-xl">
<Loader2 className="size-8 animate-spin" />
<span>Checking server health…</span>
</div>
)}
{health?.error && (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read server health: {health.error}
</div>
)}
{health && !health.error && (
<div
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-opacity ${isFetching ? "opacity-50" : ""}`}
>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<ServerIcon className="size-4 text-muted-foreground" />
Containers &amp; services
</div>
<div className="text-2xl font-semibold">
{health.containers.containerCount}
</div>
<div className="text-xs text-muted-foreground">
containers · {health.containers.serviceCount} swarm services
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Host resources
</div>
<div className="text-sm">
Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "}
{bytesToGb(health.resources.memTotalBytes)} GB
</div>
<Progress value={memUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
{health.resources.cpuCount} CPU cores
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<HardDrive className="size-4 text-muted-foreground" />
Disk (/)
</div>
<div className="text-sm">
{bytesToGb(health.disk.usedBytes)} /{" "}
{bytesToGb(health.disk.totalBytes)} GB
</div>
<Progress value={diskUsedPct} className="h-2 mt-1" />
</Card>
<Card className="p-4">
<div className="flex items-center justify-between gap-2 text-sm font-medium mb-2">
<span className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
Inotify
</span>
<Badge
variant={health.inotify.persisted ? "default" : "secondary"}
>
{health.inotify.persisted ? "persisted" : "runtime only"}
</Badge>
</div>
<div className="text-sm">
max_user_instances: {health.inotify.currentInstances} /{" "}
{health.inotify.maxInstances.toLocaleString()} (
{inotifyUsedPct}%)
</div>
<Progress value={inotifyUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "}
· max_queued_events:{" "}
{health.inotify.maxQueuedEvents.toLocaleString()}
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Network className="size-4 text-muted-foreground" />
Docker networks
</div>
<div className="text-2xl font-semibold">
{health.dockerNetworks.count}
</div>
<div className="text-xs text-muted-foreground">
The default Docker address pool is exhausted around ~30
networks unless <code>default-address-pools</code> is
configured in <code>/etc/docker/daemon.json</code>.
{health.dockerNetworks.addressPools
? " Custom pool detected on this server."
: ""}
</div>
</Card>
{health.reservation && (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Memory/CPU reservation
</div>
<div className="text-sm">
{bytesToGb(health.reservation.memoryReservedBytes)} GB
reserved ·{" "}
{nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "}
CPUs reserved
</div>
<div className="text-xs text-muted-foreground mt-2">
Across {health.reservation.appCount} Application services.
{health.reservation.unsupportedComposeCount > 0
? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.`
: ""}
</div>
</Card>
)}
</div>
)}
{health &&
!health.error &&
(health.dockerNetworks.usage.length > 0 ||
health.dockerNetworks.usageError) && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
Network IP usage
</h4>
<p className="text-xs text-muted-foreground">
IPs assigned to containers vs. the subnet's usable capacity,
per network — including reserved networks like{" "}
<code>dokploy-network</code>. A network stuck near 100% blocks
new containers from starting even when the host has plenty of
other resources free.
</p>
{health.dockerNetworks.usageError ? (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read network IP usage:{" "}
{health.dockerNetworks.usageError}
</div>
) : (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Subnet</TableHead>
<TableHead>IPs in use</TableHead>
<TableHead>Usage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{health.dockerNetworks.usage.map((n) => (
<TableRow key={n.name}>
<TableCell className="font-medium">
{n.name}
</TableCell>
<TableCell className="text-muted-foreground">
{n.driver}
</TableCell>
<TableCell className="text-muted-foreground">
{n.subnet ?? "Auto"}
</TableCell>
<TableCell className="text-muted-foreground">
{n.capacity !== null
? `${n.containersInUse} / ${n.capacity}`
: n.containersInUse}
</TableCell>
<TableCell>
{n.percentUsed !== null ? (
<Badge
variant={
n.percentUsed >= 90
? "red"
: n.percentUsed >= 70
? "yellow"
: "green"
}
>
{n.percentUsed}%
</Badge>
) : (
<span className="text-muted-foreground text-xs">
—
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{health && !health.error && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">
Docker daemon errors (
{SINCE_HOURS_OPTIONS.find(
(o) => o.value === sinceHours,
)?.label.toLowerCase()}
)
</h4>
<CodeEditor
value={daemonErrorsText}
language="shell"
disabled
lineWrapping
wrapperClassName="h-64 rounded-lg border"
/>
</div>
)}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,470 @@
"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, Layers, Loader2, Trash2 } from "lucide-react";
import { 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 ImageBase =
inferRouterOutputs<AppRouter>["dockerImage"]["getImages"][number];
type ImageRow = ImageBase & { key: string };
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 getReference = (image: ImageBase) =>
image.Repository !== "<none>" && image.Tag !== "<none>"
? `${image.Repository}:${image.Tag}`
: image.ID;
const FORCE_HINT_REGEX =
/must be forced|image is being used|referenced in multiple repositories/i;
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 ShowImageConfig = ({
imageRef,
serverId,
}: {
imageRef: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery(
{ imageRef, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View image config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Image Config</DialogTitle>
<DialogDescription>
docker image inspect output for "{imageRef}"
</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 ShowImages = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Repository", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [forcePrompt, setForcePrompt] = useState<{
image: ImageRow;
message: string;
} | null>(null);
const { data: images, isLoading } = api.dockerImage.getImages.useQuery({
serverId,
});
const { mutateAsync: removeImage } =
api.dockerImage.removeImage.useMutation();
const rows = useMemo<ImageRow[]>(
() =>
(images ?? []).map((image) => ({
...image,
key: `${image.ID}-${image.Repository}-${image.Tag}`,
})),
[images],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(image) =>
image.Repository.toLowerCase().includes(query) ||
image.Tag.toLowerCase().includes(query) ||
image.ID.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handleDelete = async (image: ImageRow, force = false) => {
try {
await removeImage({
repository: image.Repository,
tag: image.Tag,
id: image.ID,
force,
serverId,
});
toast.success("Image deleted");
setForcePrompt(null);
await utils.dockerImage.getImages.invalidate();
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (!force && FORCE_HINT_REGEX.test(message)) {
setForcePrompt({ image, message });
} else {
toast.error("Error deleting image", { description: message });
}
}
};
const columns = useMemo<ColumnDef<ImageRow>[]>(
() => [
{
accessorKey: "Repository",
header: ({ column }) => (
<SortableHeader column={column} title="Repository" />
),
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Repository}
>
{row.original.Repository}
</div>
),
},
{
accessorKey: "Tag",
header: ({ column }) => <SortableHeader column={column} title="Tag" />,
cell: ({ row }) => <Badge variant="outline">{row.original.Tag}</Badge>,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Image ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.ID}
</span>
),
},
{
id: "Size",
accessorFn: (image) => parseSize(image.Size),
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Size}</span>
),
},
{
id: "Created",
accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0,
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{row.original.CreatedSince}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowImageConfig
imageRef={getReference(row.original)}
serverId={serverId}
/>
<DialogAction
title="Delete image"
description={`The image "${getReference(row.original)}" will be removed from Docker. This action cannot be undone.`}
onClick={() => handleDelete(row.original)}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete image">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
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">
<Layers className="size-6 text-muted-foreground self-center" />
Images
</CardTitle>
<CardDescription>
Manage the Docker images 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>
) : !images?.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">
<Layers className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No images found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker images pulled or built on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by repository, tag or id..."
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 images 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>
<AlertDialog
open={!!forcePrompt}
onOpenChange={(open) => !open && setForcePrompt(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Image is in use</AlertDialogTitle>
<AlertDialogDescription>
{forcePrompt?.message} Do you want to force the deletion? This may
affect containers still using this image.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setForcePrompt(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
forcePrompt && handleDelete(forcePrompt.image, true)
}
>
Force delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -26,6 +26,11 @@ interface Props {
serviceId?: string;
}
// Sentinel the container-picker views fall back to before a real container
// is selected/auto-selected — querying logs for it just surfaces Docker's
// raw "No such container: select-a-container" daemon error.
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
export const priorities = [
{
label: "Info",
@@ -55,13 +60,16 @@ export const DockerLogsId: React.FC<Props> = ({
runType,
serviceId,
}) => {
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId: serverId ?? undefined,
},
{
enabled: !!containerId,
enabled: hasContainer,
},
);
@@ -134,7 +142,7 @@ export const DockerLogsId: React.FC<Props> = ({
};
useEffect(() => {
if (!containerId) return;
if (!hasContainer) return;
let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout;
@@ -344,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handlePauseResume}
title={isPaused ? "Resume logs" : "Pause logs"}
>
@@ -364,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
@@ -381,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 sm:w-auto w-full"
className="h-9 w-full sm:w-auto"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
@@ -410,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
@@ -425,10 +433,15 @@ export const DockerLogsId: React.FC<Props> = ({
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
) : hasContainer ? (
<div className="flex justify-center items-center h-full text-muted-foreground">
No logs found
</div>
) : (
<div className="flex justify-center items-center h-full text-center text-sm text-muted-foreground px-8">
Select a container above to view its logs. If none are listed,
make sure the service is deployed and running.
</div>
)}
</div>
</div>
@@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
<div
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
/>
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}
@@ -7,6 +7,28 @@ export interface LogLine {
message: string;
}
interface ContainerOption {
containerId: string;
}
export const resolveContainerSelection = (
currentContainerId: string | undefined,
containers: readonly ContainerOption[] | undefined,
) => {
if (!containers) {
return currentContainerId;
}
if (
currentContainerId &&
containers.some(({ containerId }) => containerId === currentContainerId)
) {
return currentContainerId;
}
return containers[0]?.containerId;
};
interface LogStyle {
type: LogType;
variant: LogVariant;
@@ -3,6 +3,7 @@ import React, { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
@@ -14,6 +15,10 @@ interface Props {
serviceId?: string;
}
// Sentinel the container-picker modal renders with before a real container
// is selected/auto-selected — never worth opening a connection for.
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
@@ -24,56 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => {
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
return;
}
const term = new Terminal({
cursorBlink: true,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
let cancelled = false;
let term: Terminal | null = null;
let ws: WebSocket | null = null;
let resizeObserver: ResizeObserver | null = null;
// Deferred a frame so React StrictMode's dev-only phantom mount+cleanup
// (which runs synchronously, before anything paints) never creates a
// terminal in the first place — dodges a known xterm.js dispose race
// (xtermjs/xterm.js#5011) where a deferred internal callback outlives
// term.dispose() and reads renderer state that's already gone.
const frame = requestAnimationFrame(() => {
if (cancelled) return;
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
term = new Terminal({
cursorBlink: true,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-expect-error
term.open(termRef.current);
term.loadAddon(addonFit);
addonFit.fit();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
term.loadAddon(addonAttach);
const sendResize = (cols: number, rows: number) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
term.onResize(({ cols, rows }) => sendResize(cols, rows));
resizeObserver = new ResizeObserver(() => addonFit.fit());
if (termRef.current) {
resizeObserver.observe(termRef.current);
}
});
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}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
return () => {
ws.readyState === WebSocket.OPEN && ws.close();
cancelled = true;
cancelAnimationFrame(frame);
resizeObserver?.disconnect();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
term?.dispose();
};
}, [containerId, activeWay, id]);
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} />
</div>
{hasContainer && (
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{hasContainer ? (
<div className="w-full h-[420px] rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} className="h-full" />
</div>
) : (
<div className="flex h-[420px] w-full items-center justify-center rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
Select a container above to open a terminal. If none are listed, make
sure the service is deployed and running.
</div>
)}
</div>
);
};
@@ -223,7 +223,7 @@ export const ShowHome = () => {
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
href="/dashboard/overview?tab=deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all →
@@ -1,5 +1,6 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
@@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
}, [data]);
useEffect(() => {
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
};
ws.onclose = (e) => {
console.log(e.reason);
if (e.reason) {
toast.error(e.reason);
}
};
return () => ws.close();
@@ -19,6 +19,11 @@ interface NetworkChartProps {
data: any[];
}
function formatNetworkGB(valueInMB: number) {
if (Number.isNaN(valueInMB)) return "0";
return (valueInMB / 1024).toFixed(1);
}
const chartConfig = {
networkIn: {
label: "Network In",
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
{latestData.networkIn} KB/s
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
Network
</span>
<span className="font-bold">
↑ {data.networkOut} KB/s
<br />↓ {data.networkIn} KB/s
↑ {formatNetworkGB(data.networkOut)} GB
<br />↓ {formatNetworkGB(data.networkIn)} GB
</span>
</div>
</div>
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const resyncMutation = api.network.resync.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
}
};
const onResync = async (networkId: string, name: string) => {
try {
await resyncMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" updated from Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error updating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
)}
</div>
{!!data?.changed.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Changed ({data.changed.length})
</span>
<span className="text-xs text-muted-foreground">
These networks were deleted and recreated in Docker under
the same name — attributes in Dokploy are outdated.
</span>
{data.changed.map((changed) => (
<div
key={changed.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm">{changed.name}</span>
{changed.driver && (
<Badge variant="outline">{changed.driver}</Badge>
)}
</div>
<Button
variant="outline"
size="xs"
isLoading={resyncMutation.isPending}
onClick={() =>
onResync(changed.networkId, changed.name)
}
>
<RotateCcw className="size-3.5" />
Update
</Button>
</div>
))}
</div>
</>
)}
{!!data?.missing.length && (
<>
<Separator />
@@ -0,0 +1,8 @@
import { Fraunces } from "next/font/google";
export const displayFont = Fraunces({
subsets: ["latin"],
weight: ["600"],
style: ["normal", "italic"],
display: "swap",
});
@@ -0,0 +1,49 @@
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
interface OnboardingState {
stepId?: string;
projectId?: string;
environmentId?: string;
}
export const isOnboardingActive = () => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
} catch {
return false;
}
};
export const markOnboardingActive = () => {
try {
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
} catch {}
};
export const clearOnboardingActive = () => {
try {
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
} catch {}
};
export const getOnboardingState = (): OnboardingState => {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
};
export const setOnboardingState = (state: OnboardingState) => {
try {
window.localStorage.setItem(
ONBOARDING_STATE_KEY,
JSON.stringify({ ...getOnboardingState(), ...state }),
);
} catch {}
};
@@ -0,0 +1,295 @@
import { defineStepper } from "@stepperize/react";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
import { CompleteStep } from "./steps/complete-step";
import { DeployStep } from "./steps/deploy-step";
import { PlanStep } from "./steps/plan-step";
import { ProjectStep } from "./steps/project-step";
import { ServerStep } from "./steps/server-step";
import { WelcomeStep } from "./steps/welcome-step";
const { useStepper, steps, Scoped } = defineStepper(
{ id: "welcome", title: "Welcome" },
{ id: "plan", title: "Pick a plan" },
{ id: "project", title: "New project" },
{ id: "server", title: "Connect server" },
{ id: "deploy", title: "Ship something" },
{ id: "complete", title: "You're live" },
);
type StepId = (typeof steps)[number]["id"];
const isStepId = (id: string | undefined): id is StepId =>
!!id && steps.some((step) => step.id === id);
interface Props {
onClose: () => void | Promise<void>;
}
export const OnboardingWizard = ({ onClose }: Props) => {
const router = useRouter();
const persisted = getOnboardingState();
const stepper = useStepper(
isStepId(persisted.stepId) ? persisted.stepId : undefined,
);
const [projectId, setProjectId] = useState<string | undefined>(
persisted.projectId,
);
const [environmentId, setEnvironmentId] = useState<string | undefined>(
persisted.environmentId,
);
const { data: isCloud = true } = api.settings.isCloud.useQuery();
const visibleStepIds = isCloud
? steps.map((step) => step.id)
: steps
.filter((step) => step.id !== "plan" && step.id !== "server")
.map((step) => step.id);
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
const goToNextVisible = () => {
const nextId = visibleStepIds[visibleIndex + 1];
if (nextId) stepper.goTo(nextId);
};
const [skipAllOpen, setSkipAllOpen] = useState(false);
const handleSkipAll = async () => {
await onClose();
router.push(
projectId && environmentId
? `/dashboard/project/${projectId}/environment/${environmentId}`
: "/dashboard/projects",
);
};
useEffect(() => {
setOnboardingState({ stepId: stepper.current.id });
}, [stepper.current.id]);
const { error: projectCheckError } = api.project.one.useQuery(
{ projectId: projectId ?? "" },
{ enabled: !!projectId, retry: false },
);
useEffect(() => {
if (!projectCheckError || !projectId) return;
setProjectId(undefined);
setEnvironmentId(undefined);
setOnboardingState({ projectId: undefined, environmentId: undefined });
stepper.goTo("project");
}, [projectCheckError, projectId]);
return (
<>
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
<div
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
<div className="flex items-center justify-between md:block">
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
<Logo className="size-7" />
<span className="text-lg font-semibold tracking-tight">
Dokploy
</span>
</div>
{!isLastVisible && (
<button
type="button"
onClick={() => setSkipAllOpen(true)}
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
>
Skip all
</button>
)}
</div>
<Scoped>
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
{visibleStepIds.map((id, index) => {
const step = steps.find((s) => s.id === id)!;
const isDone = index < visibleIndex;
const isCurrent = stepper.current.id === step.id;
return (
<li key={step.id} className="flex gap-3.5">
<div className="flex flex-col items-center">
<span
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
isCurrent
? "bg-white text-zinc-950 font-semibold"
: isDone
? "text-zinc-100"
: "text-zinc-400"
}`}
>
{isDone ? (
<CheckIcon className="size-3" />
) : (
String(index + 1).padStart(2, "0")
)}
</span>
{index < visibleStepIds.length - 1 && (
<span
className={`w-px flex-1 min-h-7 my-1.5 ${
isDone ? "bg-zinc-400" : "bg-zinc-700"
}`}
/>
)}
</div>
{isDone ? (
<button
type="button"
onClick={() => stepper.goTo(step.id)}
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
>
{step.title}
</button>
) : (
<p
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
isCurrent ? "text-white" : "text-zinc-400"
}`}
>
{step.title}
</p>
)}
</li>
);
})}
</ol>
</Scoped>
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
{!isLastVisible && (
<button
type="button"
onClick={() => setSkipAllOpen(true)}
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
>
Skip all →
</button>
)}
<div className="flex items-center gap-3 text-zinc-500">
<Link
href="https://github.com/dokploy/dokploy"
target="_blank"
className="hover:text-zinc-200 transition-colors"
>
<GithubIcon />
</Link>
<Link
href="https://x.com/getdokploy"
target="_blank"
className="hover:text-zinc-200 transition-colors"
>
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
</svg>
</Link>
<Link
href="https://discord.com/invite/2tBnJ3jDJc"
target="_blank"
className="hover:text-zinc-200 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
className="size-4"
>
<path
fill="currentColor"
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
/>
</svg>
</Link>
</div>
</div>
</div>
</aside>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
{stepper.switch({
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
plan: () => <PlanStep onNext={goToNextVisible} />,
project: () => (
<ProjectStep
onNext={(project) => {
setProjectId(project.projectId);
setEnvironmentId(project.environmentId);
setOnboardingState({
projectId: project.projectId,
environmentId: project.environmentId,
});
goToNextVisible();
}}
/>
),
server: () => <ServerStep onNext={goToNextVisible} />,
deploy: () => (
<DeployStep
environmentId={environmentId}
onNext={goToNextVisible}
/>
),
complete: () => (
<CompleteStep
projectId={projectId}
environmentId={environmentId}
onFinish={onClose}
/>
),
})}
</div>
</div>
</div>
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Skip onboarding?</DialogTitle>
<DialogDescription>
If this is your first time using Dokploy, we recommend going
through these steps — it only takes a couple of minutes and gives
you a feel for how projects, servers and deployments fit together.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
Continue setup
</Button>
<Button onClick={handleSkipAll}>Skip anyway</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,133 @@
import {
BookIcon,
DatabaseIcon,
GitMergeIcon,
GlobeIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import { Button } from "@/components/ui/button";
import { displayFont } from "../font";
interface Props {
projectId?: string;
environmentId?: string;
onFinish: () => void | Promise<void>;
}
const features = [
{
icon: DatabaseIcon,
title: "Databases",
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
},
{
icon: GlobeIcon,
title: "Custom domains",
description: "Attach your own domains and get automatic HTTPS.",
},
{
icon: GitMergeIcon,
title: "CI/CD",
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
},
{
icon: UsersIcon,
title: "Team collaboration",
description: "Invite teammates with fine-grained permissions.",
},
];
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
const router = useRouter();
const [showConfetti, setShowConfetti] = useState(false);
const [isFinishing, setIsFinishing] = useState(false);
useEffect(() => {
setShowConfetti(true);
}, []);
const projectHref =
projectId && environmentId
? `/dashboard/project/${projectId}/environment/${environmentId}`
: "/dashboard/projects";
const handleFinish = async () => {
setIsFinishing(true);
await onFinish();
router.push(projectHref);
};
return (
<div className="flex flex-col gap-10">
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
{showConfetti && (
<ConfettiExplosion
duration={3000}
force={0.35}
particleCount={200}
width={1200}
zIndex={60}
/>
)}
</div>
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Done
</span>
<h1
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
>
You're all set.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
This is just the beginning — here's some of what else you can do.
</p>
</div>
<dl className="flex flex-col divide-y">
{features.map((feature, index) => (
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
{String(index + 1).padStart(2, "0")}
</dt>
<dd className="flex flex-col gap-1">
<span className="font-medium flex items-center gap-2">
<feature.icon className="size-4 text-muted-foreground" />
{feature.title}
</span>
<span className="text-sm text-muted-foreground">
{feature.description}
</span>
</dd>
</div>
))}
</dl>
<div className="flex items-center gap-4">
<Button
size="lg"
className="w-fit px-8"
isLoading={isFinishing}
onClick={handleFinish}
>
Go to my project
</Button>
<Button variant="ghost" asChild>
<Link
href="https://docs.dokploy.com/docs/core"
target="_blank"
className="flex items-center gap-1.5"
>
<BookIcon size={14} />
Read the docs
</Link>
</Button>
</div>
</div>
);
};
@@ -0,0 +1,295 @@
import copy from "copy-to-clipboard";
import {
ArrowUpRightIcon,
CheckCircle2,
CopyIcon,
Loader2,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { displayFont } from "../font";
interface Props {
environmentId?: string;
onNext: () => void;
/** Drops the onboarding wizard's display serif for callers (e.g. the
* post-checkout welcome modal) that want the app's regular typography. */
plainTitle?: boolean;
}
const CURATED_TEMPLATES = [
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
{ id: "n8n", name: "n8n", logo: "n8n.png" },
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
];
type Deploying = { kind: "app" | "template"; id: string; url: string };
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
const titleClassName = plainTitle
? "text-xl font-semibold tracking-tight"
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
const { data: isCloud = true } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const serverId = servers?.[0]?.serverId;
const [deploying, setDeploying] = useState<Deploying | null>(null);
const [starting, setStarting] = useState<string | null>(null);
const { mutateAsync: deployNginx } =
api.application.deployNginxQuickstart.useMutation();
const { mutateAsync: deployTemplate } =
api.compose.deployTemplate.useMutation();
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
const utils = api.useUtils();
const { data: application } = api.application.one.useQuery(
{ applicationId: deploying?.id ?? "" },
{
enabled: deploying?.kind === "app",
refetchInterval: (query) => {
const status = query.state.data?.applicationStatus;
return status === "done" || status === "error" ? false : 2500;
},
},
);
const { data: compose } = api.compose.one.useQuery(
{ composeId: deploying?.id ?? "" },
{
enabled: deploying?.kind === "template",
refetchInterval: (query) => {
const status = query.state.data?.composeStatus;
return status === "done" || status === "error" ? false : 2500;
},
},
);
const status =
deploying?.kind === "app"
? application?.applicationStatus
: compose?.composeStatus;
if (!environmentId || (isCloud && !serverId)) {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
First deploy
</span>
<h1 className={titleClassName}>Deploy something.</h1>
</div>
<AlertBlock type="info" className="max-w-md">
{isCloud
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
</AlertBlock>
<Button onClick={onNext} className="w-fit px-8">
Continue
</Button>
</div>
);
}
const handleNginx = async () => {
setStarting("nginx");
try {
const res = await deployNginx({ environmentId, serverId });
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deploying the app",
);
} finally {
setStarting(null);
}
};
const handleTemplate = async (id: string) => {
setStarting(id);
try {
const composeResult = await deployTemplate({
environmentId,
serverId,
id,
});
await deployCompose({ composeId: composeResult.composeId });
const domains = await utils.domain.byComposeId.fetch({
composeId: composeResult.composeId,
});
const host = domains?.[0]?.host;
setDeploying({
kind: "template",
id: composeResult.composeId,
url: host ? `http://${host}` : "",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deploying the template",
);
} finally {
setStarting(null);
}
};
if (deploying) {
const isDone = status === "done";
const isError = status === "error";
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<span
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
isError ? "text-destructive" : "text-primary"
}`}
>
{isDone ? (
<CheckCircle2 className="size-3.5" />
) : isError ? (
<XCircleIcon className="size-3.5" />
) : (
<Loader2 className="size-3.5 animate-spin" />
)}
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
</span>
<h1 className={titleClassName}>
{isDone
? "It's live."
: isError
? "Something went wrong."
: "Building your app..."}
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
{isDone
? "Your app is up and reachable at:"
: isError
? "The deployment failed — you can check the logs from the dashboard later."
: "This usually takes a minute or two the first time."}
</p>
</div>
{isDone && deploying.url ? (
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
<span className="size-2 rounded-full bg-green-500 shrink-0" />
<span className="font-mono text-xs text-muted-foreground">
Reachable now
</span>
</div>
<div className="flex items-center justify-between gap-3 p-5">
<span className="font-mono text-sm truncate">
{deploying.url}
</span>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
copy(deploying.url);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</Button>
<Button variant="ghost" size="icon" asChild>
<a href={deploying.url} target="_blank" rel="noreferrer">
<ArrowUpRightIcon className="size-4" />
</a>
</Button>
</div>
</div>
</div>
) : !isDone && !isError ? (
<div className="rounded-2xl border p-5 max-w-md">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin shrink-0" />
Waiting for the build to finish...
</div>
</div>
) : null}
<Button
onClick={onNext}
className="w-fit px-8"
disabled={!isDone && !isError}
>
Continue
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
First deploy
</span>
<h1 className={titleClassName}>Ship something.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Pick a quickstart — we'll generate a domain and deploy it for you.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
<button
type="button"
onClick={handleNginx}
disabled={!!starting}
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
>
<div>
<p className="font-medium">Simple app</p>
<p className="text-sm text-muted-foreground mt-1">
A "Hello World" demo app, live in seconds.
</p>
</div>
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
{starting === "nginx" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Starting...
</>
) : (
"Deploy now →"
)}
</span>
</button>
<div className="flex flex-col justify-between gap-8 bg-background p-7">
<div>
<p className="font-medium">Docker Compose template</p>
<p className="text-sm text-muted-foreground mt-1">
Popular open source stacks, one click away.
</p>
</div>
<div className="flex flex-col gap-2">
{CURATED_TEMPLATES.map((template) => (
<Button
key={template.id}
variant="outline"
size="sm"
disabled={!!starting}
isLoading={starting === template.id}
onClick={() => handleTemplate(template.id)}
className="justify-start"
>
<img
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
alt={template.name}
className="size-4 object-contain shrink-0"
/>
{template.name}
</Button>
))}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,213 @@
import { loadStripe } from "@stripe/stripe-js";
import { ArrowRightIcon, CheckIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
calculatePriceHobby,
calculatePriceStartup,
STARTUP_SERVERS_INCLUDED,
} from "@/components/dashboard/settings/billing/show-billing";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { displayFont } from "../font";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
interface Props {
onNext: () => void;
}
export const PlanStep = ({ onNext }: Props) => {
const [loadingTier, setLoadingTier] = useState<
"hobby" | "startup" | "trial" | null
>(null);
const { data } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
const { mutateAsync: startFreeTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const handleCheckout = async (tier: "hobby" | "startup") => {
if (!data) return;
const productId =
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
if (!productId) return;
setLoadingTier(tier);
try {
const stripe = await stripePromise;
const session = await createCheckoutSession({
tier,
productId,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: false,
});
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
} catch {
toast.error("Error starting checkout");
setLoadingTier(null);
}
};
const handleTrial = async () => {
setLoadingTier("trial");
try {
await startFreeTrial();
await utils.project.onboardingStatus.invalidate();
toast.success("Your 14-day trial has started");
onNext();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setLoadingTier(null);
}
};
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Billing
</span>
<h1
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
>
Start free, upgrade when ready.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
No credit card for the trial — add one only if you decide to stay.
</p>
</div>
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
<div>
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
Recommended
</span>
<p className={`${displayFont.className} text-2xl mt-3`}>
14-day free trial
</p>
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
No card required — cancel anytime.
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"1 server included",
"Unlimited apps & databases",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
>
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
isLoading={loadingTier === "trial"}
disabled={loadingTier !== null}
onClick={handleTrial}
>
Start trial
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Hobby</p>
<p className="text-sm text-muted-foreground mt-1">
For individual developers
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${calculatePriceHobby(1, false).toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"1 server included",
"Unlimited apps & databases",
"2 environments",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "hobby"}
disabled={loadingTier !== null || !data?.hobbyProductId}
onClick={() => handleCheckout("hobby")}
>
Subscribe
</Button>
</div>
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Startup</p>
<p className="text-sm text-muted-foreground mt-1">
For small to mid-size teams
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
$
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
2,
)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
`${STARTUP_SERVERS_INCLUDED} servers included`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "startup"}
disabled={loadingTier !== null || !data?.startupProductId}
onClick={() => handleCheckout("startup")}
>
Subscribe
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,118 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { displayFont } from "../font";
const schema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
type Schema = z.infer<typeof schema>;
interface Props {
onNext: (project: { projectId: string; environmentId: string }) => void;
/** Drops the onboarding wizard's display serif for callers (e.g. the
* post-checkout welcome modal) that want the app's regular typography. */
plainTitle?: boolean;
}
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
const titleClassName = plainTitle
? "text-xl font-semibold tracking-tight"
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
const { mutateAsync, isPending } = api.project.create.useMutation();
const utils = api.useUtils();
const form = useForm<Schema>({
defaultValues: { name: "My First Project", description: "" },
resolver: zodResolver(schema),
});
const onSubmit = async (data: Schema) => {
try {
const result = await mutateAsync(data);
await utils.project.all.invalidate();
toast.success("Project created");
onNext({
projectId: result.project.projectId,
environmentId: result.environment?.environmentId ?? "",
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error creating the project",
);
}
};
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Workspace
</span>
<h1 className={titleClassName}>Create your first project.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Projects group your apps, databases and environments together.
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-5 max-w-sm"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My First Project" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="What is this project for?"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={isPending}
className="w-fit px-8 mt-2"
>
Create project
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,346 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { displayFont } from "../font";
const schema = z.object({
name: z.string().min(1, "Name is required"),
ipAddress: z
.string()
.min(1, "IP address is required")
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
});
type Schema = z.infer<typeof schema>;
interface Props {
onNext: () => void;
/** Drops the onboarding wizard's display serif for callers (e.g. the
* post-checkout welcome modal) that want the app's regular typography. */
plainTitle?: boolean;
}
const StatusRow = ({
label,
ok,
pending,
}: {
label: string;
ok?: boolean;
pending?: boolean;
}) => (
<div className="flex items-center gap-2 text-sm">
{ok ? (
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
) : pending ? (
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
) : (
<XCircle className="size-4 text-muted-foreground shrink-0" />
)}
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
</div>
);
export const ServerStep = ({ onNext, plainTitle }: Props) => {
const titleClassName = plainTitle
? "text-xl font-semibold tracking-tight"
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
const generateSSHKey = api.sshKey.generate.useMutation();
const createSSHKey = api.sshKey.create.useMutation();
const hasCreatedKey = useRef(false);
const cloudSSHKey = sshKeys?.find(
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
);
useEffect(() => {
const ensureKey = async () => {
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
hasCreatedKey.current = true;
try {
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
await createSSHKey.mutateAsync({
name: "dokploy-onboarding-ssh-key",
description: "Used during onboarding",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
organizationId: "",
});
await refetchSSHKeys();
} catch {
hasCreatedKey.current = false;
}
};
ensureKey();
}, [sshKeys]);
const { data: existingServers } = api.server.all.useQuery();
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
useEffect(() => {
if (!createdServerId && existingServers && existingServers.length > 0) {
setCreatedServerId(existingServers[0]!.serverId);
}
}, [existingServers, createdServerId]);
const { data: canCreateMoreServers } =
api.stripe.canCreateMoreServers.useQuery();
const { mutateAsync: createServer, isPending: isCreating } =
api.server.create.useMutation();
const checkIsReady = (validation?: {
docker?: { enabled?: boolean };
isDokployNetworkInstalled?: boolean;
}) =>
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
const {
data: validation,
refetch: refetchValidation,
isFetching: isValidating,
} = api.server.validate.useQuery(
{ serverId: createdServerId ?? "" },
{
enabled: !!createdServerId,
refetchInterval: (query) =>
checkIsReady(query.state.data) ? false : 5000,
},
);
const isReady = checkIsReady(validation);
const [isSettingUp, setIsSettingUp] = useState(false);
const hasStartedSetup = useRef(false);
useEffect(() => {
if (createdServerId && !hasStartedSetup.current && !isReady) {
hasStartedSetup.current = true;
setIsSettingUp(true);
}
}, [createdServerId, isReady]);
useEffect(() => {
if (!isSettingUp) return;
const timeout = setTimeout(() => {
setIsSettingUp(false);
toast.error(
"Setup is taking too long — check the server is reachable, then try again.",
);
}, 120_000);
return () => clearTimeout(timeout);
}, [isSettingUp]);
api.server.setupWithLogs.useSubscription(
{ serverId: createdServerId ?? "" },
{
enabled: !!createdServerId && isSettingUp,
onData(log) {
if (typeof log === "string" && log.includes("Setup Server:")) {
setIsSettingUp(false);
refetchValidation();
}
},
onError(error) {
setIsSettingUp(false);
toast.error("Error setting up the server");
console.error(error);
},
},
);
const form = useForm<Schema>({
defaultValues: { name: "My First Server", ipAddress: "" },
resolver: zodResolver(schema),
});
const onSubmit = async (data: Schema) => {
if (!cloudSSHKey) {
toast.error("Still generating your SSH key, try again in a moment");
return;
}
try {
const server = await createServer({
name: data.name,
description: "",
ipAddress: data.ipAddress.trim(),
port: 22,
username: "root",
sshKeyId: cloudSSHKey.sshKeyId,
serverType: "deploy",
enableDockerCleanup: false,
});
setCreatedServerId(server.serverId);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error creating the server",
);
}
};
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Infrastructure
</span>
<h1 className={titleClassName}>Connect a server.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Dokploy deploys to servers you own — buy one from any VPS provider
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
</p>
</div>
{!createdServerId ? (
<div className="flex flex-col gap-4 max-w-sm">
{canCreateMoreServers === false && (
<AlertBlock type="warning">
You'll need a plan or trial before connecting a server — go back
to the "Pick a plan" step.
</AlertBlock>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<span className="text-xs font-medium text-muted-foreground">
1. Run this on your server to authorize Dokploy
</span>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
{cloudSSHKey
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
: "Generating..."}
</code>
{cloudSSHKey && (
<button
type="button"
onClick={() => {
copy(
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="size-4 text-muted-foreground" />
</button>
)}
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Server name</FormLabel>
<FormControl>
<Input placeholder="My First Server" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>2. IP address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={isCreating}
disabled={!cloudSSHKey || canCreateMoreServers === false}
className="mt-2"
>
Connect server
</Button>
</form>
</Form>
</div>
) : (
<div className="flex flex-col gap-4 w-full max-w-sm">
<div className="flex flex-col gap-3 rounded-lg border p-4">
{isSettingUp ? (
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin shrink-0" />
Setting up your server — installing Docker and dependencies...
</div>
) : isValidating && !validation ? (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking your server...
</div>
) : (
<>
<StatusRow
label="Docker installed"
ok={validation?.docker?.enabled}
pending={!validation?.docker?.enabled && !isReady}
/>
<StatusRow
label="Dokploy network created"
ok={validation?.isDokployNetworkInstalled}
pending={!validation?.isDokployNetworkInstalled && !isReady}
/>
</>
)}
</div>
{!isReady && !isSettingUp && (
<AlertBlock type="info">
Setup usually takes a minute after the server boots — this checks
automatically. You can retry setup, or use "Skip for now" above to
continue and come back to this later.
</AlertBlock>
)}
<div className="flex gap-2">
<Button
variant="secondary"
className="flex-1"
isLoading={isValidating || isSettingUp}
onClick={() => {
if (!isReady && !isSettingUp) {
setIsSettingUp(true);
} else {
refetchValidation();
}
}}
>
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
</Button>
<Button
className="flex-1"
disabled={!isReady || isSettingUp}
onClick={onNext}
>
Continue
</Button>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,71 @@
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { displayFont } from "../font";
interface Props {
onNext: () => void;
}
const points = [
{
icon: GitBranchIcon,
title: "Deploy from Git or Docker",
description:
"Push a repo, a compose file, or a container image and go live.",
},
{
icon: PuzzleIcon,
title: "One-click templates",
description:
"WordPress, databases, and dozens of open source apps, pre-wired.",
},
{
icon: ServerIcon,
title: "Your own servers",
description: "Runs on infrastructure you control — no vendor lock-in.",
},
];
export const WelcomeStep = ({ onNext }: Props) => {
return (
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Welcome
</span>
<h1
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
>
Let's get your first app <em className="not-italic">live</em>.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
A few steps — pick a plan, connect a server, ship something. You'll
have a working URL by the end.
</p>
</div>
<dl className="flex flex-col divide-y">
{points.map((point, index) => (
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
{String(index + 1).padStart(2, "0")}
</dt>
<dd className="flex flex-col gap-1">
<span className="font-medium flex items-center gap-2">
<point.icon className="size-4 text-muted-foreground" />
{point.title}
</span>
<span className="text-sm text-muted-foreground">
{point.description}
</span>
</dd>
</div>
))}
</dl>
<Button size="lg" onClick={onNext} className="w-fit px-8">
Get started
</Button>
</div>
);
};
@@ -1,6 +1,7 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { GlobeIcon, PenBoxIcon, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -14,7 +15,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@@ -25,6 +26,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { resizeImage } from "@/utils/image-processing";
import { sanitizeSvg } from "@/utils/sanitize-svg";
const organizationSchema = z.object({
name: z.string().min(1, {
@@ -38,10 +41,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props {
organizationId?: string;
children?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
export function AddOrganization({
organizationId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: Props) {
const [internalOpen, setInternalOpen] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const uploadCounter = useRef(0);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled
? controlledOnOpenChange || (() => {})
: setInternalOpen;
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
@@ -49,6 +66,7 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@@ -65,14 +83,18 @@ export function AddOrganization({ organizationId }: Props) {
useEffect(() => {
if (organization) {
uploadCounter.current++;
setIsUploading(false);
form.reset({
name: organization.name,
logo: organization.logo || "",
});
setUploadedFileName(null);
}
}, [organization, form]);
const onSubmit = async (values: OrganizationFormValues) => {
if (isUploading) return;
await mutateAsync({
name: values.name,
logo: values.logo,
@@ -80,6 +102,7 @@ export function AddOrganization({ organizationId }: Props) {
})
.then(() => {
form.reset();
setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@@ -99,20 +122,109 @@ export function AddOrganization({ organizationId }: Props) {
});
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const currentUploadId = ++uploadCounter.current;
setIsUploading(true);
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"image/webp",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
setIsUploading(false);
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
setIsUploading(false);
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
try {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (currentUploadId !== uploadCounter.current) return;
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
form.setValue("logo", sanitizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing SVG");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
return;
}
// Resize raster images to max 256x256 and convert to WebP to save space
try {
const resizedDataUrl = await resizeImage(file, 256);
if (currentUploadId !== uploadCounter.current) return;
form.setValue("logo", resizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing image");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
if (!val) {
uploadCounter.current++;
setIsUploading(false);
}
setOpen(val);
}}
>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
title="Edit organization"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
</Button>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
<button
type="button"
className="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
@@ -120,7 +232,7 @@ export function AddOrganization({ organizationId }: Props) {
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
@@ -159,23 +271,78 @@ export function AddOrganization({ organizationId }: Props) {
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem className="gap-4">
<FormLabel className="text-right">Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
render={({ field }) => {
const isDataUrl = field.value?.startsWith("data:");
const displayValue = isDataUrl
? uploadedFileName || "Uploaded image"
: field.value || "";
return (
<FormItem className="gap-4">
<FormLabel className="text-right">
Logo URL or Upload
</FormLabel>
<FormControl>
<div className="col-span-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 p-1">
{field.value ? (
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
<img
src={field.value}
alt="Logo preview"
className="size-full object-contain"
/>
) : (
<GlobeIcon className="size-5 text-muted-foreground" />
)}
</div>
<div className="relative flex-1">
<Input
placeholder="https://example.com/logo.png"
{...field}
value={displayValue}
readOnly={isDataUrl}
onChange={(e) => {
uploadCounter.current++;
setIsUploading(false);
field.onChange(e);
if (isDataUrl) setUploadedFileName(null);
}}
className="w-full pr-8"
/>
{field.value && (
<button
type="button"
onClick={() => {
uploadCounter.current++;
setIsUploading(false);
form.setValue("logo", "");
setUploadedFileName(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</div>
<Dropzone
dropMessage="Drag & drop a logo or click to upload"
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
classNameContent="h-32"
/>
</div>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
);
}}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
<Button type="submit" isLoading={isPending || isUploading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
@@ -0,0 +1,254 @@
import type { OverviewBackup } from "@dokploy/server/services/overview-shared";
import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared";
import {
ArrowUpDown,
CircuitBoard,
GlobeIcon,
Loader2,
RefreshCw,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const STATUS_VARIANT: Record<string, "default" | "destructive" | "secondary"> =
{
done: "default",
running: "secondary",
error: "destructive",
cancelled: "destructive",
};
const renderRowIcon = (row: OverviewBackup) => {
const icon = getBackupOverviewIcon(row);
if (icon.kind === "db") {
const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS];
return Icon ? <Icon className="h-5 w-5" /> : null;
}
if (icon.kind === "webServer") {
return <ServerIcon className="h-5 w-5 text-muted-foreground" />;
}
return icon.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
const detailHref = (row: OverviewBackup) => {
if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null;
return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`;
};
export const ShowOverviewBackups = () => {
const {
data: backups,
isFetching,
isFetched,
isError,
refetch,
} = api.overview.backups.useQuery(undefined, {
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
});
const [destinationId, setDestinationId] = useState("all");
const [serviceOwnerId, setServiceOwnerId] = useState("all");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const destinations = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) map.set(row.destinationId, row.destinationName);
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const services = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) {
if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName);
}
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const rows = useMemo(() => {
if (!backups) return [];
const filtered = backups.filter(
(row) =>
(destinationId === "all" || row.destinationId === destinationId) &&
(serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId),
);
const sorted = [...filtered].sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return sortDirection === "asc" ? sorted : sorted.reverse();
}, [backups, destinationId, serviceOwnerId, sortDirection]);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Backups{" "}
{isFetched && (
<span className="text-sm font-normal text-muted-foreground">
({rows.length})
</span>
)}
</h3>
<div className="flex flex-wrap items-center gap-2">
{isFetched && (
<>
<Select value={destinationId} onValueChange={setDestinationId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All destinations</SelectItem>
{destinations.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceOwnerId}
onValueChange={setServiceOwnerId}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All services</SelectItem>
{services.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortDirection((d) => (d === "desc" ? "asc" : "desc"))
}
>
<ArrowUpDown className="size-4 mr-2" />
{sortDirection === "desc" ? "Newest first" : "Oldest first"}
</Button>
</>
)}
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>
</div>
</div>
{isFetching && !isFetched && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading backups...
</div>
)}
{isFetched && isError && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>Failed to load backups.</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
)}
{isFetched && !isError && rows.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground">
No backups match the current filters.
</div>
)}
{isFetched && !isError && rows.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Service</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const href = detailHref(row);
const name = href ? (
<Link href={href} className="hover:underline">
{row.serviceName}
</Link>
) : (
row.serviceName
);
return (
<TableRow key={row.deploymentId}>
<TableCell className="whitespace-nowrap text-sm">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{renderRowIcon(row)}
{name}
</div>
</TableCell>
<TableCell>{row.destinationName}</TableCell>
<TableCell>
<Badge variant="outline">
{row.kind === "backup" ? "Database" : "Volume"}
</Badge>
</TableCell>
<TableCell>
{row.status && (
<Badge
variant={STATUS_VARIANT[row.status] ?? "secondary"}
>
{row.status}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</Card>
);
};
@@ -0,0 +1,77 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const;
type SubtabValue = (typeof SUBTAB_VALUES)[number];
const DEFAULT_SUBTAB: SubtabValue = "deployments";
function isValidSubtab(t: string): t is SubtabValue {
return SUBTAB_VALUES.includes(t as SubtabValue);
}
export const ShowOverviewDeployments = () => {
const router = useRouter();
const subtab =
typeof router.query.subtab === "string" &&
isValidSubtab(router.query.subtab)
? router.query.subtab
: DEFAULT_SUBTAB;
const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DEFAULT_SUBTAB ? query : { ...query, subtab: value },
},
undefined,
{ shallow: true },
);
};
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<Rocket className="size-5" />
Deployments
</CardTitle>
<CardDescription>
All application and compose deployments in one place.
</CardDescription>
</div>
</div>
<Tabs
value={subtab}
onValueChange={setSubtab}
className="w-full min-w-0"
>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
</TabsContent>
</Tabs>
</CardHeader>
</div>
</Card>
);
};
@@ -0,0 +1,383 @@
import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared";
import { sortOverviewDomains } from "@dokploy/server/services/overview-shared";
import { ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { COMPOSE_REDEPLOY_TOAST } from "@/components/dashboard/application/domains/redeploy-hint";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "port-asc", label: "Port (low-high)" },
{ value: "port-desc", label: "Port (high-low)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
export const ShowOverviewDomains = () => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const canToggleDomain = permissions?.domain.create ?? false;
const { data: domains, isLoading } = api.overview.domains.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedSsl, setSelectedSsl] = useState("all");
const [selectedPort, setSelectedPort] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewDomainSortBy>(
"overviewDomainsSort",
"createdAt-desc",
SORT_VALUES,
);
const handleToggleEnable = async (
domain: NonNullable<typeof domains>[number],
) => {
try {
const result = await toggleEnable({ domainId: domain.domainId });
utils.overview.domains.invalidate();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error(`Error updating "${domain.host}"`);
}
};
const availablePorts = useMemo(() => {
if (!domains) return [];
const ports = new Set<number>();
for (const domain of domains) {
if (domain.port !== null) ports.add(domain.port);
}
return Array.from(ports).sort((a, b) => a - b);
}, [domains]);
const filteredDomains = useMemo(() => {
if (!domains) return [];
const filtered = domains.filter(
(domain) =>
(selectedProjectId === "all" ||
domain.projectId === selectedProjectId) &&
(selectedStatus === "all" ||
(selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) &&
(selectedSsl === "all" ||
(selectedSsl === "https" ? domain.https : !domain.https)) &&
(selectedPort === "all" || domain.port === Number(selectedPort)),
);
return sortOverviewDomains(filtered, sortBy);
}, [
domains,
selectedProjectId,
selectedStatus,
selectedSsl,
selectedPort,
sortBy,
]);
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedDomains = filteredDomains.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Domains{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredDomains.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<Select value={selectedSsl} onValueChange={setSelectedSsl}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="SSL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">HTTP & HTTPS</SelectItem>
<SelectItem value="https">HTTPS only</SelectItem>
<SelectItem value="http">HTTP only</SelectItem>
</SelectContent>
</Select>
{availablePorts.length > 0 && (
<Select value={selectedPort} onValueChange={setSelectedPort}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Port" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ports</SelectItem>
{availablePorts.map((port) => (
<SelectItem key={port} value={String(port)}>
{port}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewDomainSortBy)}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading domains...
</div>
)}
{!isLoading && filteredDomains.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No domains match the current filters.</span>
</div>
)}
{!isLoading && filteredDomains.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Host</TableHead>
<TableHead>Path</TableHead>
<TableHead>Port</TableHead>
<TableHead>Entrypoint</TableHead>
<TableHead>Protocol</TableHead>
<TableHead>Certificate</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedDomains.map((domain) => {
const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`;
return (
<TableRow key={domain.domainId}>
<TableCell>
<Link href={href} className="flex flex-col min-w-0">
<span className="font-medium truncate">
{domain.serviceName}
</span>
<span className="text-xs font-normal text-muted-foreground">
{domain.projectName} / {domain.environmentName}
</span>
</Link>
</TableCell>
<TableCell>
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
</TableCell>
<TableCell>
<span className="font-mono text-sm">
{domain.path || "/"}
</span>
</TableCell>
<TableCell>
<Badge variant="secondary">{domain.port}</Badge>
</TableCell>
<TableCell>
{domain.customEntrypoint ? (
<span className="font-mono text-sm">
{domain.customEntrypoint}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant={domain.https ? "outline" : "secondary"}>
{domain.https ? "HTTPS" : "HTTP"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
</TableCell>
<TableCell>
<DateTooltip date={domain.createdAt} />
</TableCell>
<TableCell className="text-right">
{canToggleDomain ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-end">
<Switch
checked={domain.enabled}
onCheckedChange={() =>
handleToggleEnable(domain)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.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>
) : (
<Badge
variant={domain.enabled ? "outline" : "secondary"}
>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredDomains.length}{" "}
{filteredDomains.length === 1 ? "domain" : "domains"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -0,0 +1,550 @@
import type {
OverviewServiceType,
OverviewSortBy,
} from "@dokploy/server/services/overview-shared";
import { sortOverviewServices } from "@dokploy/server/services/overview-shared";
import {
Ban,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
Search,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const TYPE_LABELS = {
application: "Application",
postgres: "PostgreSQL",
mariadb: "MariaDB",
mongo: "MongoDB",
mysql: "MySQL",
redis: "Redis",
compose: "Compose",
libsql: "Libsql",
} satisfies Record<OverviewServiceType, string>;
const STATUS_OPTIONS = ["running", "idle", "done", "error"];
// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect.
const STATUS_LABELS: Record<string, string> = {
running: "Deploying",
idle: "Idle",
done: "Running",
error: "Error",
};
const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [
{ value: "lastDeploy-desc", label: "Recently deployed" },
{ value: "lastDeploy-asc", label: "Oldest deployed" },
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "name-asc", label: "Name (A-Z)" },
{ value: "name-desc", label: "Name (Z-A)" },
{ value: "type-asc", label: "Type (A-Z)" },
{ value: "type-desc", label: "Type (Z-A)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
// libsql has no deploy/stop actions, same as environment/[environmentId].tsx.
const idKeyByType: Record<string, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
redis: "redisId",
mongo: "mongoId",
};
export const ShowOverviewServices = () => {
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.overview.services.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const [searchQuery, setSearchQuery] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedType, setSelectedType] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedServerId, setSelectedServerId] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewSortBy>(
"overviewServicesSort",
"lastDeploy-desc",
SORT_VALUES,
);
const applicationActions = {
deploy: api.application.deploy.useMutation(),
stop: api.application.stop.useMutation(),
};
const composeActions = {
deploy: api.compose.deploy.useMutation(),
stop: api.compose.stop.useMutation(),
};
const postgresActions = {
deploy: api.postgres.deploy.useMutation(),
stop: api.postgres.stop.useMutation(),
};
const mysqlActions = {
deploy: api.mysql.deploy.useMutation(),
stop: api.mysql.stop.useMutation(),
};
const mariadbActions = {
deploy: api.mariadb.deploy.useMutation(),
stop: api.mariadb.stop.useMutation(),
};
const redisActions = {
deploy: api.redis.deploy.useMutation(),
stop: api.redis.stop.useMutation(),
};
const mongoActions = {
deploy: api.mongo.deploy.useMutation(),
stop: api.mongo.stop.useMutation(),
};
const actionsByType: Record<
string,
{
deploy: { mutateAsync: (input: any) => Promise<unknown> };
stop: { mutateAsync: (input: any) => Promise<unknown> };
}
> = {
application: applicationActions,
compose: composeActions,
postgres: postgresActions,
mysql: mysqlActions,
mariadb: mariadbActions,
redis: redisActions,
mongo: mongoActions,
};
const handleAction = async (
service: NonNullable<typeof services>[number],
action: "deploy" | "stop",
) => {
const actions = actionsByType[service.type];
const idKey = idKeyByType[service.type];
if (!actions || !idKey) return;
const labels =
action === "deploy"
? {
loading: "Deploying",
success: "queued for deployment",
error: "deploying",
}
: { loading: "Stopping", success: "stopped", error: "stopping" };
toast.promise(
(async () => {
await actions[action].mutateAsync({ [idKey]: service.id });
})(),
{
loading: `${labels.loading} ${service.name}...`,
success: () => {
utils.overview.services.invalidate();
return `${service.name} ${labels.success} successfully`;
},
error: (error) =>
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
};
const availableServers = useMemo(() => {
if (!services) return [];
const map = new Map<string, string>();
for (const service of services) {
if (service.serverId && service.serverName) {
map.set(service.serverId, service.serverName);
}
}
return Array.from(map.entries()).map(([serverId, serverName]) => ({
serverId,
serverName,
}));
}, [services]);
const hasServicesWithoutServer = useMemo(
() => !!services?.some((service) => !service.serverId),
[services],
);
const filteredServices = useMemo(() => {
if (!services) return [];
const filtered = services.filter(
(service) =>
service.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(selectedProjectId === "all" ||
service.projectId === selectedProjectId) &&
(selectedType === "all" || service.type === selectedType) &&
(selectedStatus === "all" || service.status === selectedStatus) &&
(selectedServerId === "all" ||
(selectedServerId === "dokploy-server" && !service.serverId) ||
service.serverId === selectedServerId),
);
return sortOverviewServices(filtered, sortBy);
}, [
services,
searchQuery,
selectedProjectId,
selectedType,
selectedStatus,
selectedServerId,
sortBy,
]);
// pageIndex is clamped against the filtered count each render instead of reset via an effect.
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedServices = filteredServices.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
const renderIcon = (service: NonNullable<typeof services>[number]) => {
if (service.type in DB_ENGINE_ICONS) {
const Icon =
DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS];
return <Icon className="h-5 w-5" />;
}
if (service.icon) {
return (
<img
src={service.icon}
alt={service.name}
className="size-5 object-contain rounded-sm"
/>
);
}
return service.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Services{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredServices.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-9 w-[200px]"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status] ?? status}
</SelectItem>
))}
</SelectContent>
</Select>
{(availableServers.length > 0 || hasServicesWithoutServer) && (
<Select
value={selectedServerId}
onValueChange={setSelectedServerId}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All servers</SelectItem>
{hasServicesWithoutServer && (
<SelectItem value="dokploy-server">
Dokploy server
</SelectItem>
)}
{availableServers.map((s) => (
<SelectItem key={s.serverId} value={s.serverId}>
{s.serverName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewSortBy)}
>
<SelectTrigger className="w-[190px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading services...
</div>
)}
{!isLoading && filteredServices.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No services match the current filters.</span>
</div>
)}
{!isLoading && filteredServices.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Deploy</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedServices.map((service) => {
const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`;
const hasActions = service.type in actionsByType;
return (
<TableRow key={service.id}>
<TableCell>
<Link href={href} className="flex items-center gap-2">
{renderIcon(service)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{service.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{service.projectName} / {service.environmentName}
</span>
</div>
</Link>
</TableCell>
<TableCell>{TYPE_LABELS[service.type]}</TableCell>
<TableCell>
<StatusTooltip
status={service.status as any}
className="size-2.5"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ServerIcon className="size-3.5" />
<span className="truncate">
{service.serverName ?? "Dokploy server"}
</span>
</div>
</TableCell>
<TableCell>
<DateTooltip date={service.createdAt} />
</TableCell>
<TableCell>
{service.lastDeployAt ? (
<DateTooltip date={service.lastDeployAt} />
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="truncate">
{service.name}
</DropdownMenuLabel>
<DialogAction
title="Deploy Service"
description={`Are you sure you want to deploy "${service.name}"?`}
type="default"
onClick={() => handleAction(service, "deploy")}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={(e) => e.preventDefault()}
>
<RefreshCw className="size-4" />
Deploy
</DropdownMenuItem>
</DialogAction>
<DialogAction
title="Stop Service"
description={`Are you sure you want to stop "${service.name}"?`}
onClick={() => handleAction(service, "stop")}
>
<DropdownMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onSelect={(e) => e.preventDefault()}
>
<Ban className="size-4" />
Stop
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredServices.length}{" "}
{filteredServices.length === 1 ? "service" : "services"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -156,7 +157,17 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={data?.projectId}
environmentId={environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -152,7 +153,16 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={projectId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -17,11 +17,13 @@ import {
Download,
Globe,
InfoIcon,
Loader2,
Server,
TrendingUpIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
pageSize: 10,
});
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
const {
data: statsLogs,
isLoading,
isError,
error,
} = api.settings.readStatsLogs.useQuery(
{
sort: sorting[0],
page: pagination,
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
colSpan={columns.length}
className="h-24 text-center"
>
{statsLogs?.data.length === 0 && (
{isLoading ? (
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading requests...</span>
</div>
) : isError ? (
<div className="w-full flex items-center justify-center h-[55vh]">
<AlertBlock type="error" className="w-full">
{error?.message}
</AlertBlock>
</div>
) : (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No results.
@@ -175,7 +175,7 @@ export const SearchCommand = () => {
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/deployments");
router.push("/dashboard/overview?tab=deployments");
setOpen(false);
}}
>
@@ -4,6 +4,7 @@ import {
AlertTriangle,
Bell,
CheckIcon,
Clock,
CreditCard,
FileText,
Loader2,
@@ -93,6 +94,7 @@ export const ShowBilling = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
const { data, isPending } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
@@ -103,8 +105,26 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const handleStartTrial = async () => {
try {
await startFreeTrial();
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
]);
toast.success("Your 14-day trial has started");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
}
};
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
const [startupServerQuantity, setStartupServerQuantity] = useState(
STARTUP_SERVERS_INCLUDED,
@@ -249,6 +269,94 @@ export const ShowBilling = () => {
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
{!isEnterpriseCloud &&
billingStatus &&
(billingStatus.plan || billingStatus.isOnTrial) && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
<div className="flex items-center gap-3">
{billingStatus.isOnTrial ? (
<Clock className="h-5 w-5 text-primary shrink-0" />
) : (
<CreditCard className="h-5 w-5 text-primary shrink-0" />
)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{billingStatus.isOnTrial
? "Free trial"
: "Current plan"}
</span>
<Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"}
</Badge>
</div>
<span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
billingStatus.trialEndsAt
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
: ""
}`
: "You're subscribed and billed automatically."}
</span>
</div>
</div>
{billingStatus.isOnTrial &&
admin?.user.stripeCustomerId && (
<Button
size="sm"
variant="outline"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Add payment method
</Button>
)}
</div>
)}
{!isEnterpriseCloud &&
billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
14-day free trial
</span>
<span className="text-sm text-muted-foreground">
No credit card required — cancel anytime.
</span>
<ul className="flex flex-col gap-1 mt-1">
{[
"1 server included",
"Unlimited apps & databases",
"Community support",
].map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{feature}
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
isLoading={isStartingTrial}
onClick={handleStartTrial}
>
Start trial
</Button>
</div>
)}
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -1,61 +0,0 @@
import { useEffect, useState } from "react";
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
export const ShowWelcomeDokploy = () => {
const { data } = api.user.get.useQuery();
const [open, setOpen] = useState(false);
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
if (!isCloud || data?.role !== "admin") {
return null;
}
useEffect(() => {
if (
!isPending &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.role === "owner"
) {
setOpen(true);
}
}, [isCloud, isPending]);
const handleClose = (isOpen: boolean) => {
if (data?.role === "owner") {
setOpen(isOpen);
if (!isOpen) {
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
}
}
};
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-center">
Welcome to Dokploy Cloud 🎉
</DialogTitle>
<p className="text-center text-sm text-muted-foreground mt-2">
Unlock powerful features to streamline your deployments and manage
projects effortlessly.
</p>
</DialogHeader>
<div className="mt-4 space-y-3 text-sm text-primary ">
<ShowBilling />
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
serverId,
});
const { data: registry } = api.registry.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && (
{haveAtLeastOneRegistry && permissions?.server.create && (
<div className="flex flex-row gap-2">
<AddNode serverId={serverId} />
</div>
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
{!node?.ManagerStatus?.Leader &&
permissions?.server.delete && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -0,0 +1,421 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cloud, CloudOff, XIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
export const DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
type RecordType = (typeof DNS_RECORD_TYPES)[number];
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
const valueFields: Record<
RecordType,
{ label: string; placeholder: string; hint?: string }
> = {
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
CNAME: { label: "Target", placeholder: "app.example.com" },
MX: {
label: "Mail server",
placeholder: "10 mail.example.com",
hint: "Start with the priority, then the mail server.",
},
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
SRV: {
label: "Target",
placeholder: "1 10 5269 talk.example.com",
hint: "Priority, weight, port, then target.",
},
CAA: {
label: "Value",
placeholder: '0 issue "letsencrypt.org"',
hint: "Flags, tag, then the quoted value.",
},
PTR: { label: "Target", placeholder: "host.example.com" },
};
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
CAA: /^\d+\s+\S+\s+.+$/,
};
const DnsRecordSchema = z
.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
const values = data.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!values.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: "Content is required",
});
return;
}
const pattern = structuredValuePatterns[data.type];
if (pattern && !values.every((value) => pattern.test(value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: `Expected ${valueFields[data.type].placeholder}`,
});
}
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
export interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record: DnsRecordValue | null;
onClose: () => void;
}
export const DnsRecordPanel = ({
dnsProviderId,
zoneId,
zoneName,
record,
onClose,
}: Props) => {
const utils = api.useUtils();
const createRecord = api.dnsProvider.createRecord.useMutation();
const updateRecord = api.dnsProvider.updateRecord.useMutation();
const { mutateAsync, isPending, error, isError } = record
? updateRecord
: createRecord;
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: servers } = api.server.all.useQuery();
const { data: panelPublicIp } = api.server.publicIp.useQuery();
const { data: panelStoredIp } = api.settings.getIp.useQuery();
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
? record.type
: "A") as RecordType,
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
proxied: record.proxied ?? false,
}
: { type: "A", name: "", content: "", ttl: "", proxied: false },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const proxied = form.watch("proxied");
const canProxy =
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
const supportsMultipleValues = provider?.providerType === "route53";
const usesAutomaticTtl = canProxy && proxied;
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const isProxied = canProxy && data.proxied;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
...(canProxy && { proxied: data.proxied }),
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
onClose();
})
.catch(() => {});
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{record ? "Edit record" : "New record"}
</span>
<span className="text-xs text-muted-foreground">{zoneName}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Close panel"
>
<XIcon className="size-4" />
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{DNS_RECORD_TYPES.map((recordType) => (
<SelectItem key={recordType} value={recordType}>
{recordType}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{valueFields[type].label}</FormLabel>
<FormControl>
{supportsMultipleValues ? (
<Textarea
className="min-h-[60px] font-mono text-xs"
placeholder={valueFields[type].placeholder}
{...field}
/>
) : (
<Input
placeholder={valueFields[type].placeholder}
{...field}
/>
)}
</FormControl>
{(supportsMultipleValues || valueFields[type].hint) && (
<FormDescription>
{[
valueFields[type].hint,
supportsMultipleValues &&
"One value per line: every line belongs to the same record set.",
]
.filter(Boolean)
.join(" ")}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{canProxy && (
<FormField
control={form.control}
name="proxied"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy status</FormLabel>
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
<legend className="sr-only">Proxy status</legend>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
field.value && "translate-x-full",
)}
/>
<button
type="button"
aria-pressed={!field.value}
onClick={() => field.onChange(false)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
<CloudOff className="size-3.5" />
DNS only
</button>
<button
type="button"
aria-pressed={field.value}
onClick={() => field.onChange(true)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-[#f6821f]"
: "text-muted-foreground hover:text-foreground",
)}
>
<Cloud className="size-3.5" />
Proxied
</button>
</fieldset>
<FormDescription>
{field.value
? "Traffic runs through Cloudflare and the origin IP stays hidden."
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
className="tabular-nums"
disabled={usesAutomaticTtl}
{...field}
/>
</FormControl>
{usesAutomaticTtl && (
<FormDescription>
Proxied records always use automatic TTL.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
</div>
);
};

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