Compare commits

...

153 Commits

Author SHA1 Message Date
Mauricio Siu 9226c2d531 fix(sandbox): block inter-sandbox traffic, restart container when the timeout kill cannot fork, skip sandboxes mid-creation in reconcile 2026-09-10 01:38:43 -06:00
Mauricio Siu 0c38a498ef fix(sandbox): reap orphaned processes with docker-init, reject directory reads, buffer early websocket messages 2026-09-10 01:30:15 -06:00
Mauricio Siu fec13d604e docs(sandbox): API reference, security model and limitations 2026-09-10 01:16:57 -06:00
Mauricio Siu 3145478595 test(sandbox): container options, tar, file listing, exec runner and reaper 2026-09-10 01:16:56 -06:00
Mauricio Siu e3954f7915 feat(sandbox): sandboxes list and detail UI 2026-09-10 01:16:55 -06:00
Mauricio Siu ad00da50c1 feat(sandbox): tRPC router, OpenAPI exposure and streaming exec websocket 2026-09-10 01:16:54 -06:00
Mauricio Siu 084fe765f0 feat(sandbox): service, exec runner, file helpers and reaper 2026-09-10 01:16:53 -06:00
Mauricio Siu c8db68b637 feat(sandbox): add sandbox schema and migration 2026-09-10 00:49:49 -06:00
Mauricio Siu 88118cdb3f Merge pull request #5411 from Dokploy/fix/fresh-volumes-danger-zone
fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation
2026-09-09 17:48:16 -06:00
Mauricio Siu 38d157adc0 fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation 2026-09-09 17:47:16 -06:00
Mauricio Siu 3b1fec5409 Merge pull request #5410 from Dokploy/feat/compose-pull-images
feat(compose): pull latest images on deploy toggle
2026-09-09 17:14:43 -06:00
Mauricio Siu 49b8214e5d feat(compose): add pull latest images on deploy toggle 2026-09-09 17:13:24 -06:00
Mauricio Siu ec31739e0e Merge pull request #5396 from Dokploy/feat/transfer-service-between-servers
feat: transfer services between servers
2026-09-09 13:21:27 -06:00
Narciso E. Núñez Arias d5328703fa Merge pull request #5407 from imrja8/fix-sso-flash 2026-09-09 13:15:29 -04:00
Narciso E. Núñez Arias f4cb26cbf4 Merge pull request #5406 from imrja8/fix-settings-layout-width 2026-09-09 13:12:41 -04:00
Yash Kumar a6c1f3048f fix(ui): standardize settings layout widths to full width 2026-09-09 21:24:03 +05:30
Yash Kumar 9cae609a0a fix(auth): prefetch SSO state in SSR to prevent layout flash 2026-09-09 20:09:23 +05:30
Mauricio Siu 2c234d6e20 test(transfer): shrink pipe payload and set an explicit timeout so the suite does not time out in CI 2026-09-09 02:57:51 -06:00
Mauricio Siu 9dcdb53aa1 feat(transfer): move services between servers with their volumes, mounts and config
Adds a Transfer action on every service page that moves an application,
compose or database to another server without S3: volumes, bind mounts
and deployment logs are streamed through the panel (ssh2/spawn pipe),
file mounts and Traefik config are recreated on the target, the source
is cleaned up and the service is deployed on the target. Failures before
cleanup roll back to the source server.
2026-09-09 02:50:29 -06:00
Mauricio Siu bda8124291 Merge pull request #5351 from Dokploy/fix/dependabot-critical-security-updates
fix: resolve 6 critical Dependabot alerts
2026-09-08 16:29:38 -06:00
Mauricio Siu 85786a3196 Merge pull request #5386 from Dokploy/fix/server-health-address-pools
fix(server-health): detect custom default-address-pools via docker info
2026-09-08 16:04:50 -06:00
Mauricio Siu ea3f9f3b53 fix(server-health): detect custom default-address-pools via docker info
/etc/docker/daemon.json isn't mounted into the dokploy container, only
docker.sock is, so cat-ing it always failed silently. Read the effective
config over the already-mounted socket instead.

Fixes #5383
2026-09-08 16:01:48 -06:00
Narciso E. Núñez Arias e99a98e4fe Merge pull request #5375 from aspatari/feat/infisical-path-in-reference
feat(vault): address an Infisical folder from the reference
2026-09-08 16:54:01 -04:00
Artur Spatari 5611662351 feat(vault): address an Infisical folder from the reference
An Infisical provider is pinned to one non-recursive `secretPath`, so reading
two folders means two providers, two machine identities and two sets of
credentials to rotate. This lets a reference name the folder instead:

    ${{vault.my-provider.external/sentry:SENTRY_DSN}}

`<path>:<KEY>` mirrors the HashiCorp client in this directory, which already
documents that exact format. A relative path resolves against the provider's
`secretPath`, a leading slash is absolute, and a ref without a colon keeps its
current meaning — the whole ref is the secret name at the provider's own path.

A dot cannot be the separator here: Infisical accepts dots inside secret names
(`A.B.C` is a valid key), so `provider.a.b.C` cannot be split unambiguously
and would silently break anyone using such a name.

Refs are grouped by resolved path so each path is listed once, and the login
happens once per batch rather than once per path.

Tests cover the bare ref, relative and absolute paths, a provider at `/`,
grouping with a single login, the error naming the path, and a malformed ref.
2026-09-08 21:38:24 +03:00
Narciso E. Núñez Arias 887a457f15 Merge pull request #5374 from aspatari/fix/infisical-expand-secret-references
fix(vault): expand Infisical secret references when listing secrets
2026-09-08 14:08:52 -04:00
autofix-ci[bot] df4e299545 [autofix.ci] apply automated fixes 2026-09-08 17:43:47 +00:00
Narciso E. Núñez Arias aaff71bbf6 Merge pull request #5382 from imrja8/fix/dns-upsert-preserve-existing-records
fix(dns): match record content in upsertRecord to prevent overwriting existing records
2026-09-08 13:39:32 -04:00
Yash Kumar be128d5a4a Merge remote-tracking branch 'upstream/canary' into fix/dns-upsert-preserve-existing-records
# Conflicts:
#	packages/server/src/utils/dns/infomaniak.ts
2026-09-08 22:54:00 +05:30
Narciso E. Núñez Arias 467c80a73d Merge pull request #5356 from mitc-gjuge/feat/infomaniak-record-filter
perf(dns): filter Infomaniak records server-side when upserting
2026-09-08 13:13:43 -04:00
Narciso E. Núñez Arias 2d5a6ef679 feat: add dynamic open graph metadata for link previews (#5348)
* feat: add dynamic open graph metadata for enterprise whitelabeling
2026-09-08 13:05:09 -04:00
Yash Kumar 8291165259 Update packages/server/src/utils/dns/porkbun.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-08 17:32:11 +05:30
autofix-ci[bot] e701644fa5 [autofix.ci] apply automated fixes 2026-09-08 11:54:43 +00:00
Yash Kumar 94cd5035ef fix(dns): extract and normalize prio for porkbun MX/SRV records to prevent duplicates 2026-09-08 17:24:17 +05:30
Yash Kumar ea1c6fc3fe fix(dns): fallback to data fields comparison for cloudflare structured SRV/CAA records 2026-09-08 17:20:59 +05:30
Yash Kumar df0986b4e1 fix(dns): apply content normalization in upsert match logic to prevent duplicates 2026-09-08 17:13:58 +05:30
Yash Kumar caca3c19ae Resolve merge conflicts in drizzle migrations 2026-09-08 17:06:17 +05:30
Yash Kumar 91db4e60c4 fix(dns): match record content in upsertRecord to prevent overwriting existing records
Previously, upsertRecord matched only by type and name, silently
overwriting any existing record of the same type/name with different
content. This caused data loss when users had multiple records of the
same type (e.g. multiple TXT records for SPF + site verification).

Now upsertRecord also checks the record content/target before deciding
to update. If the content differs, a new record is created alongside
the existing one instead of replacing it.

Affected providers: Cloudflare, Porkbun, Infomaniak, OVH.
Tests updated accordingly.
2026-09-08 16:59:16 +05:30
Mauricio Siu 1e6ab0fab7 Merge pull request #5380 from Dokploy/fix/sso-account-not-linked-trust
fix(sso): trust admin-configured SSO providers for account linking
2026-09-08 03:17:44 -06:00
Mauricio Siu 67f6890fc8 fix(sso): trust admin-configured SSO providers for account linking
better-auth's account-linking guard requires either the IdP asserting
email_verified or the sso_provider row being domainVerified before it
will link an SSO login to an existing local user with the same email.
Entra ID never asserts email_verified (neither via its OIDC userinfo
endpoint nor via SAML attributes), and Dokploy never persisted
domainVerified at all, so linking was permanently impossible for any
Entra-backed provider (OIDC or SAML) regardless of the local user's
own emailVerified state.

Add the domainVerified column, defaulting to true — Dokploy already
scopes providers to an admin-configured domain and gates registration
behind enterprise/admin permissions, so that domain match (still
enforced by better-auth's validateEmailDomain check) is trust enough.

Fixes #5099
2026-09-08 03:17:02 -06:00
Mauricio Siu 988f5b64d6 feat(trial-banner): add trial banner component to display trial status in sidebar 2026-09-08 01:26:41 -06:00
Mauricio Siu e369541a16 fix(billing): update server inclusion descriptions for clarity 2026-09-08 01:01:55 -06:00
Mauricio Siu 7c1889205c Merge pull request #5379 from Dokploy/fix/terminal-permissions-and-misc-bugs
fix(domains): stop AddDomain queries flooding the API in table view
2026-09-08 00:52:16 -06:00
Mauricio Siu 0f28775a1e fix(domains): don't fetch AddDomain queries until the dialog opens
Fixes #5279. The Domains table (and grid) view mounts one AddDomain
instance per row for the edit action. Its 4 queries (domain.one,
application.one/compose.one, domain.canGenerateTraefikMeDomains,
compose.loadServices) ran unconditionally on mount instead of only
when the edit dialog is open, so any table interaction that causes a
re-render (typing in the host filter, sorting, toggling columns) fired
all 4 queries for every domain row again.
2026-09-08 00:51:05 -06:00
Yash Kumar 6ed5746983 Resolve merge conflicts in drizzle migrations 2026-09-08 12:12:32 +05:30
Mauricio Siu e8f94a94da Merge pull request #5373 from kcybe/fix/update-modal-close-alignment
fix(ui): align close button in web server update modal
2026-09-08 00:37:15 -06:00
Mauricio Siu 150c34dc0a Merge pull request #5378 from Dokploy/chore/trial-7-days
Reduce trial duration from 14 to 7 days
2026-09-08 00:25:42 -06:00
Mauricio Siu 91eaab6a43 Reduce trial duration from 14 to 7 days 2026-09-08 00:17:40 -06:00
Mauricio Siu f0f7843ece Merge pull request #5266 from its-felix/codex/5265-aws-parameter-store
feat(vault): add AWS Parameter Store provider
2026-09-08 00:09:07 -06:00
Artur Spatari 6e760da93b fix(vault): expand Infisical secret references when listing secrets
A secret in Infisical can reference another one (`${env.folder.KEY}`), which
is the only way to keep a value in one place and read it from several folders.
The Infisical client fetches `/api/v3/secrets/raw` without
`expandSecretReferences`, and that endpoint leaves references untouched by
default. The referencing secret therefore arrives as the literal `${...}`
string, is written to the generated `.env`, and the deployment still reports
success — the service just receives a placeholder instead of its value.

This is easy to miss because the single-secret endpoint (`/raw/{name}`) does
expand by default, so the Infisical UI and API show the resolved value while
only Dokploy sees the literal.

Adds the parameter and the first tests for this client: the list request now
asserts the flag, a missing secret and an auth failure. Without the fix the
new test fails with `expected null to be 'true'`.
2026-09-07 22:47:43 +03:00
kcybe 0c19973fbf fix(ui): align close button in web server update modal
Keep the close control in the header row so it lines up with the title and version badge instead of overlapping them.
2026-09-07 21:58:00 +03:00
Mauricio Siu 600657ec62 Add new journal entry for "0193_chemical_the_liberteens" with version 7 2026-09-07 11:34:21 -06:00
Mauricio Siu 383438e4fc Merge branch 'canary' into codex/5265-aws-parameter-store 2026-09-07 11:33:45 -06:00
Mauricio Siu 57a5465d89 Remove unused journal entry for "confused_rocket_racer" from _journal.json 2026-09-07 11:33:31 -06:00
Narciso E. Núñez Arias ab87f2daad Merge pull request #5273 from pparage/fix/issue-4658-interface-addresses
fix(domains): include server interface addresses in validation
2026-09-07 13:05:21 -04:00
Narciso E. Núñez Arias 3b065db8a0 Merge pull request #5258 from mitc-gjuge/feat/ovh-dns-provider
feat: add OVHcloud DNS provider support
2026-09-07 11:47:10 -04:00
Guillaume Juge 8409b8ce60 perf(dns): filter Infomaniak records server-side when upserting
upsertRecord pulled every record of a zone just to find the one it was about to
write. The API can filter, so ask it to.

The match is still verified locally, because filter[source] matches substrings
rather than exact values. Verified against a live account:

    filter[source]=autoconfig  -> [autoconfig]
    filter[source]=auto        -> [autoconfig, autodiscover]
    filter[source]=disco       -> [autodiscover]

Letting the filter decide would therefore have upserted "auto" onto the existing
"autoconfig" record. The filter narrows what is transferred; the exact
comparison still decides. Covered by a test that fails if the first candidate is
trusted.

The apex was the other thing worth checking, since Infomaniak writes it as ".":
filter[source]=. does return the apex records, and an upsert on an existing apex
TXT returns its id rather than creating a second one.

Suggested by @narcisonunez on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 11:48:44 +02:00
Guillaume Juge 57d92b75de chore: drop local scratch files committed by mistake
A git add -A while merging canary swept in .watch/, a local scratch directory
holding monitoring scripts, screenshots and draft comment text. None of it
belongs in this PR.
2026-09-05 11:47:13 +02:00
Guillaume Juge 7e9fdbb5ff Merge canary and regenerate the migration after Infomaniak landed
#5257 merged, so canary now carries the Infomaniak provider and its
0191_cool_christian_walker. This branch's 0191_elite_ultragirl collided on the
number, and the conflicts were the usual "infomaniak and ovh added at the same
spot" across the enum, the config union, the client registry, SENSITIVE_FIELDS,
both providerLabels maps, the icons and the provider form — all resolved by
keeping both.

Regenerated as 0192_light_lake with a current timestamp rather than renaming the
file, so drizzle still applies it on a database that has already run canary's
0191.
2026-09-05 11:21:50 +02:00
Guillaume Juge 2311f346d4 fix(dns): do not report a restored OVH record as lost
restoreRecord had the restore POST and its zone refresh inside one catch. That
was harmless while refreshZone swallowed failures, but the previous commit made
it throw, which brought a new case into that catch: the restore succeeds and
only the publication fails.

The message then told the user the record "has been deleted" and to recreate it
by hand. It exists at OVH, just unpublished, so following that advice duplicates
it as soon as the zone is refreshed.

The two failures are now reported separately. A failed POST still means the
record is really gone and prints what to recreate. A failed refresh after a
successful restore says the record is back but not served yet, and explicitly
says not to recreate it. Either way the original replacement error is kept, so
the user still learns why the type change failed.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 11:19:26 +02:00
Yash Kumar e9623119bd Merge canary and resolve conflicts by regenerating migration 2026-09-05 11:03:54 +05:30
Narciso E. Núñez Arias d7884c7ce9 Merge pull request #5257 from mitc-gjuge/feat/infomaniak-dns-provider
feat: add Infomaniak DNS provider support
2026-09-04 17:44:11 -04:00
Guillaume Juge 28f71e727a fix(dns): report an OVH zone refresh failure for what it is
refreshZone runs after the record write has already succeeded, so a failure
there means the change exists at the provider but is not being served yet. It
was left unguarded at all six call sites, so the caller saw a bare transport
error and would reasonably read it as "nothing was applied" — while a type
change had already deleted the old record and created its replacement.

Rolling the write back would destroy correct state over a publish failure, and
re-deleting after an uncertain network error is how records get lost. The error
now says what actually happened and how it resolves: the next successful change
to the zone publishes it, or the user can refresh the zone from the OVH manager.

Guarding the helper covers upsertRecord and deleteRecord too, not just the type
change Greptile pointed at.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:23:43 +02:00
Yash Kumar 29e5940e8d chore: revert accidental package.json changes 2026-09-04 22:47:18 +05:30
autofix-ci[bot] 41251b4f1e [autofix.ci] apply automated fixes 2026-09-04 17:13:12 +00:00
Yash Kumar 15bf663aad fix: make fallback og image url absolute for social scrapers 2026-09-04 22:42:48 +05:30
Yash Kumar 13b043edd9 fix: replace metaTitle with ogImageUrl in reset payloads 2026-09-04 22:40:27 +05:30
autofix-ci[bot] 48dfb2b820 [autofix.ci] apply automated fixes 2026-09-04 17:03:38 +00:00
Yash Kumar 62e948a804 Merge branch 'Dokploy:canary' into feat/open-graph-metadata 2026-09-04 21:39:41 +05:30
Yash Kumar b6c83e082e chore: use compressed open graph image 2026-09-04 21:37:15 +05:30
Yash Kumar b5c3d8660e feat: add dynamic open graph metadata for enterprise whitelabeling 2026-09-04 20:56:23 +05:30
Narciso E. Núñez Arias 5c0d3b84af fix(auth): enforce SSO on server-side sign-in endpoints (#5291)
* fix(auth): enforce SSO on server-side sign-in endpoints

Resolves #5288.

- In better-auth hooks.before, reject /sign-in/email and /sign-in/social with FORBIDDEN when webServerSettings.enforceSSO is enabled
- Prevents direct API password authentication when SSO enforcement is enabled

* [autofix.ci] apply automated fixes

* fix(auth): block passkey authentication and email signup when SSO is enforced

- Include /sign-in/passkey, /passkey/verify-authentication, /passkey/generate-authenticate-options, and /sign-up/email in enforced SSO block
- Prevents registered passkeys from bypassing enforced SSO policies

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-04 11:09:33 -04:00
Mauricio Siu 7effae35cb Merge pull request #5296 from Dokploy/detail/fix-docs/docs-drop-stale-mcp-json-reference-in-fix-issue-sk-68178b
docs: drop stale .mcp.json reference in fix-issue skill
2026-09-04 09:04:20 -06:00
Mauricio Siu 7fb02abb3e Merge pull request #5295 from Dokploy/detail/fix-docs/docs-fix-reset-password-command-invocation-in-cont-f3c26a
docs: fix reset-password command invocation in CONTRIBUTING
2026-09-04 09:02:58 -06:00
Mauricio Siu 631816f8d1 Merge pull request #5297 from Dokploy/detail/dead-code/chore-remove-unused-filteremptyvalues-and-hasvalue-dd3edc
chore: remove unused filterEmptyValues and hasValues utils
2026-09-04 09:02:05 -06:00
Yash Kumar 1a29694481 feat(ui): smart truncation tooltips, org name limits, and logo UI polish (#5345) 2026-09-04 10:03:44 -04:00
Narciso 19589694ea fix: resolve 6 critical Dependabot alerts
- tar >=7.5.19 (GHSA-23hp-3jrh-7fpw): decompression DoS, pinned via
  pnpm override within 7.x (transitive via @mapbox/node-pre-gyp)
- protobufjs >=7.5.5 (GHSA-xq3m-2v4x-88gg): arbitrary code execution,
  pinned via pnpm override within 7.x (transitive via @grpc/proto-loader,
  @opentelemetry/otlp-transformer)
- shell-quote ^1.8.4 (GHSA-w7jw-789q-3m8p): quote() didn't escape
  newlines in object .op values (direct dep, used for docker/ssh
  command building)
- vitest ^4.1.0 (GHSA-5xrq-8626-4rwp): arbitrary file read/execute via
  UI server (devDependency)
- github.com/gofiber/fiber/v2 v2.52.11 (GHSA-68rr-p4fp-j59v): insecure
  UUIDv4 fallback on crypto/rand failure (apps/monitoring)
- next (GHSA-f82v-jwr5-mffw): authorization bypass in middleware, was
  a transitive dep of react-email in the standalone
  packages/server/src/emails lockfile; regenerating against the
  current react-email version (6.9.3) drops next.js entirely, since
  it no longer uses it for the preview server

Verified: typecheck (server + app) clean, full vitest suite
(947 passed / 5 env-conditional skips, 0 failures), go build/vet/test
clean for apps/monitoring.
2026-09-03 17:51:40 -04: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
somuai 5f10ed6884 fix(auth): block passkey authentication and email signup when SSO is enforced
- Include /sign-in/passkey, /passkey/verify-authentication, /passkey/generate-authenticate-options, and /sign-up/email in enforced SSO block
- Prevents registered passkeys from bypassing enforced SSO policies
2026-09-04 01:11:04 +05:30
detail-app[bot] 399e20c1ed chore: remove unused filterEmptyValues and hasValues utils 2026-09-03 19:15:40 +00:00
detail-app[bot] 5faa363f7a docs: drop stale .mcp.json reference in fix-issue skill 2026-09-03 18:53:08 +00:00
detail-app[bot] 5cbc7efc61 docs: fix reset-password command invocation in CONTRIBUTING 2026-09-03 18:53:07 +00: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] 9568a4f9f8 [autofix.ci] apply automated fixes 2026-09-03 11:10:44 +00:00
somuai b839e6d6be fix(auth): enforce SSO on server-side sign-in endpoints
Resolves #5288.

- In better-auth hooks.before, reject /sign-in/email and /sign-in/social with FORBIDDEN when webServerSettings.enforceSSO is enabled
- Prevents direct API password authentication when SSO enforcement is enabled
2026-09-03 16:40:21 +05:30
Guillaume Juge 7ae566f4de Merge remote-tracking branch 'origin/canary' into feat/ovh-dns-provider 2026-09-03 10:29:31 +02:00
Guillaume Juge ea9a466c01 Merge remote-tracking branch 'origin/canary' into feat/infomaniak-dns-provider 2026-09-03 10:28:56 +02: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
Philippe Parage e79ed7691f fix(domains): include server interface addresses in validation 2026-09-02 15:05:27 +02:00
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 6dacab06cd Remove unused journal entry for tag "0190_nappy_anita_blake" from _journal.json 2026-09-02 02:20:27 -06:00
Mauricio Siu b2ed974422 Merge branch 'canary' into codex/5265-aws-parameter-store 2026-09-02 01:57:43 -06:00
Mauricio Siu 62e43b164b Remove unused journal entry for wide Korg from _journal.json 2026-09-02 01:57:34 -06:00
Guillaume Juge 4746cfc1ca Merge canary and regenerate the migration at a free number
canary landed its own 0190 (0190_nappy_anita_blake), which collides with this
branch's 0190_even_mastermind. Renaming would not have been enough: drizzle
applies migrations by comparing timestamps against the last applied one, so this
branch's older `when` would have been silently skipped on any deployment that
had already run canary's 0190, leaving the enum without its new value.

Regenerated as 0191_elite_ultragirl with a current timestamp.
2026-09-02 08:58:09 +02:00
Guillaume Juge 18e617eb14 Merge canary and regenerate the migration at a free number
canary landed its own 0190 (0190_nappy_anita_blake), which collides with this
branch's 0190_perpetual_red_skull. Renaming would not have been enough: drizzle
applies migrations by comparing timestamps against the last applied one, so this
branch's older `when` would have been silently skipped on any deployment that
had already run canary's 0190, leaving the enum without its new value.

Regenerated as 0191_cool_christian_walker with a current timestamp.
2026-09-02 08:57:29 +02:00
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
Felix 1d26fdd027 feat(vault): add AWS Parameter Store provider 2026-09-02 04:36:21 +02: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
Guillaume Juge f83097b80f fix(dns): spell out the OVH right that lists zones
OVH matches access rules per exact path: a `GET /domain/zone/*` rule grants the
subtree but not the bare `GET /domain/zone` that listZones and testConnection
call. Verified against a live account with a consumer key carrying that single
wildcard rule:

    GET /domain/zone                     -> 403 This call has not been granted
    GET /domain/zone/                    -> 200
    GET /domain/zone/{zone}/record       -> 200

The form only asked for rights on `/domain/zone/*`, so a token created by
following it could not list zones at all, and the failure surfaced as a bare
"This call has not been granted" that points nowhere.

The hint now lists the five rights verbatim, and a token missing the root one
gets an error that names it instead of echoing OVH's message.

Reported by @narcisonunez on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:30:07 +02:00
Guillaume Juge 59a74849a8 fix(dns): paginate the documented Infomaniak products endpoint
listZones used /1/product, which lego uses but the API docs don't: it is the
legacy singular route, it returns no pagination metadata, and it 401s for at
least some tokens. The documented /1/products returns `total`, `pages`,
`items_per_page` and `page`, and defaults to 15 domains per page, so an account
with more domains than that would silently lose zones.

Switch to the plural endpoint and walk every page. Verified against a live
account: forcing per_page=2 collects all 5 domains across 3 pages with no
duplicates.

Also inline the single-use createdId helper.

Both reported by @narcisonunez on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:02:54 +02:00
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
Guillaume Juge 80a6baf58e fix(dns): restore the original OVH record when a type change fails
Changing a record's type deletes the record then recreates it with the new type,
because OVH's update payload carries no fieldType. If the creation failed the
name was left with nothing and no rollback.

The delete still has to come first, since OVH rejects a CNAME that would sit
alongside other data on the same name. So on a failed creation the original
record is put back from the copy already fetched before the delete, and the
original error is rethrown. If the restore fails too, the error names the record
that has to be recreated by hand.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:29:07 +02:00
Guillaume Juge be4a5da405 fix(dns): match apex records stored under any apex spelling
toSource always writes the apex as ".", but listRecords already accepted "" and
"@" as apex spellings on read. The upsert lookup compared sources strictly, so a
record stored under one of the other spellings would not have matched and the
upsert would have created a duplicate apex record instead of updating it.

Normalize the candidate's source before comparing, so read and match agree.

Reported by Greptile on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:28:04 +02:00
Guillaume Juge 2bc22c520d fix(dns): represent the Infomaniak apex as a root dot
Verified against a live Infomaniak account: the API returns `source: "."` for
apex records, not "" as assumed.

Reading them back produced a doubled dot ("..example.com"), and writing "" meant
an apex upsert never matched the existing record, so it would have created a
duplicate apex record instead of updating it.

toSource now emits "." for the apex and toFqdn accepts ".", "" and "@" so a
hand-written record still round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:58:31 +02:00
Guillaume Juge e629248dea feat: add OVHcloud DNS provider support
Adds OVHcloud alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.

- ovh.ts implements listZones, listRecords, upsertRecord, updateRecord,
  deleteRecord and testConnection against the /domain/zone endpoints of the
  OVHcloud API, on any of its seven regional endpoints (ovh-eu/ca/us, kimsufi
  and soyoustart).
- A new `ovh` value was added to the DnsProviderType enum along with an
  ovhDnsConfigSchema (endpoint, applicationKey, applicationSecret, consumerKey)
  in the discriminated union, plus the Drizzle migration for the enum change.
- The application secret and the consumer key are masked/merged like the other
  providers' secrets in services/dns-provider.ts.
- UI: OVHcloud icon, an endpoint selector and the three credential fields in the
  DNS provider dialog, plus registration in the provider selector.

Three OVH-specific behaviours are handled explicitly:

- Requests are signed with `$1$` + sha1(applicationSecret+consumerKey+method+
  url+body+timestamp). The timestamp comes from the API's own clock via an
  unauthenticated GET /auth/time, since a host clock a few seconds off would get
  every call rejected; the measured drift is cached per endpoint for an hour.
- OVH only applies zone changes once the zone is explicitly refreshed, so every
  successful create, update and delete is followed by POST /domain/zone/{zone}
  /refresh.
- The record update payload carries no fieldType, so changing a record's type
  replaces the record (DELETE then POST) and returns the new id.

The record listing endpoint returns ids only, so each record is fetched
individually with the fan-out capped at 8 concurrent requests.

Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:29:01 +02:00
Guillaume Juge ff3ebefc70 feat: add Infomaniak DNS provider support
Adds Infomaniak alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.

- infomaniak.ts implements listZones, listRecords, upsertRecord, updateRecord,
  deleteRecord and testConnection against the Infomaniak API. Zones come from
  /1/product?service_name=domain and records from the v2 /2/zones/{zone}/records
  endpoints, which are keyed by zone name rather than by product id.
- A new `infomaniak` value was added to the DnsProviderType enum along with an
  infomaniakDnsConfigSchema (apiToken) in the discriminated union, plus the
  Drizzle migration for the enum change.
- The token is masked/merged like the other providers in services/dns-provider.ts.
- UI: Infomaniak icon and API Token field in the DNS provider dialog, plus
  registration in the provider selector.

Infomaniak's `source` is relative to the zone (empty for the apex), so record
names are translated between Dokploy's fully-qualified format and Infomaniak's
subdomain-only format internally, with the trailing dot handled. TXT targets are
stored quoted by the API and are unquoted on read / quoted on write so that
editing a record does not stack quotes on every save.

Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:25:03 +02:00
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
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
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
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
202 changed files with 86891 additions and 5807 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
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
Note: `mcp__dokploy__*` 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
+2 -2
View File
@@ -123,13 +123,13 @@ pnpm run docker:push
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
pnpm --filter=dokploy 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
pnpm --filter=dokploy run reset-password user@example.com
```
Both commands print the new randomly generated password to the console.
+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`.
+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"
@@ -0,0 +1,44 @@
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,
composePath: "./docker-compose.yml",
command: "",
};
describe("compose createCommand --pull always", () => {
it("adds --pull always when pullImages is enabled", () => {
const cmd = createCommand({ ...base, pullImages: true } as any);
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
});
it("omits --pull when pullImages is disabled", () => {
const cmd = createCommand({ ...base, pullImages: false } as any);
expect(cmd).not.toContain("--pull");
});
it("does not add --pull to stack deploy", () => {
const cmd = createCommand({
...base,
composeType: "stack",
pullImages: true,
} as any);
expect(cmd).not.toContain("--pull");
});
it("leaves a custom command untouched", () => {
const cmd = createCommand({
...base,
command: "compose -p compose-app up -d",
pullImages: true,
} as any);
expect(cmd).toBe("compose -p compose-app up -d");
});
});
@@ -54,7 +54,16 @@ const inputEncoding: Record<string, string> = {
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 });
+23 -2
View File
@@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
expect(createInit.method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
@@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
expect(updateInit.method).toBe("PUT");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
@@ -0,0 +1,486 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
const ikPage = (data: unknown, page: number, pages: number) =>
jsonResponse({ result: "success", data, page, pages });
const ikError = (description: string, status = 400) =>
jsonResponse(
{ result: "error", error: { code: "not_authorized", description } },
false,
status,
);
const config = {
providerType: "infomaniak" as const,
apiToken: "ik_test_token",
};
const lastCall = () =>
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
const lastBody = () => JSON.parse(lastCall()[1].body as string);
beforeEach(() => {
mockFetch.mockReset();
});
describe("infomaniakClient.listZones", () => {
it("exposes each domain product as a zone keyed by its name", async () => {
mockFetch.mockResolvedValue(
ikPage(
[
{ id: 1, customer_name: "example.com" },
{ id: 2, customer_name: "example.ch" },
],
1,
1,
),
);
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.ch", name: "example.ch" },
]);
const [url, init] = lastCall();
// The documented endpoint is the plural one; the singular is legacy and
// returns no pagination metadata at all.
expect(url).toContain("/1/products?service_name=domain");
expect(url).toContain("page=1");
expect(init.headers).toMatchObject({
Authorization: "Bearer ik_test_token",
});
});
it("walks every page so accounts with many domains keep all their zones", async () => {
mockFetch
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
const zones = await infomaniakClient.listZones(config);
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
});
it("stops after a single page when the response has no pagination", async () => {
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("propagates the API error description", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
"Authorization required",
);
});
});
describe("infomaniakClient.listRecords", () => {
it("rebuilds the fqdn from the relative source", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "10",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
{
id: "11",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 600,
},
]);
expect(lastCall()[0]).toBe(
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
);
});
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
mockFetch.mockResolvedValue(
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.name).toBe("example.com");
});
it("unquotes TXT targets", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 13,
type: "TXT",
source: "_acme-challenge",
target: '"token-value"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe("token-value");
});
it("leaves a CAA target untouched", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 14,
type: "CAA",
source: "",
target: '0 issue "letsencrypt.org"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
});
});
describe("infomaniakClient.upsertRecord", () => {
it("creates the record when no matching source and type exists", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "42" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
expect(lastBody()).toEqual({
type: "A",
source: "app",
target: "1.2.3.4",
ttl: 600,
});
});
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody().ttl).toBe(300);
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "50" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
});
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(lastBody().source).toBe(".");
});
it.each([".", "", "@"])(
"matches an existing apex record stored with a %s source",
async (source) => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "8" });
expect(lastCall()[1].method).toBe("PUT");
},
);
it("matches the existing apex record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 8,
type: "TXT",
source: ".",
target: '"v=spf1 -all"',
ttl: 3600,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "example.com",
content: "v=spf1 -all",
});
expect(result).toEqual({ id: "8" });
const [url, init] = lastCall();
expect(init.method).toBe("PUT");
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/8",
);
expect(lastBody().target).toBe('"v=spf1 -all"');
});
it("quotes a TXT target on write", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: "token-value",
});
expect(lastBody().target).toBe('"token-value"');
});
it("does not double-quote a TXT target that is already quoted", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: '"token-value"',
});
expect(lastBody().target).toBe('"token-value"');
});
it("queries the API with a source and type filter instead of the whole zone", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("filter%5Bsource%5D=app");
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
});
it("ignores a partial filter hit rather than overwriting a different record", async () => {
// filter[source] matches substrings: asking for "auto" also returns
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 61,
type: "CNAME",
source: "autoconfig",
target: "a.example.net",
ttl: 300,
},
{
id: 62,
type: "CNAME",
source: "autodiscover",
target: "b.example.net",
ttl: 300,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "auto.example.com",
content: "c.example.net",
});
expect(result).toEqual({ id: "63" });
expect(lastCall()[1].method).toBe("POST");
});
});
describe("infomaniakClient.updateRecord", () => {
it("updates the record and keeps its id", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
const result = await infomaniakClient.updateRecord(
config,
"example.com",
"7",
{
type: "CNAME",
name: "www.example.com",
content: "example.com",
ttl: 900,
},
);
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody()).toEqual({
type: "CNAME",
source: "www",
target: "example.com",
ttl: 900,
});
});
it("falls back to the default ttl when none is provided", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
await infomaniakClient.updateRecord(config, "example.com", "7", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(lastBody().ttl).toBe(300);
});
});
describe("infomaniakClient.deleteRecord", () => {
it("deletes the record", async () => {
mockFetch.mockResolvedValue(ikSuccess(null));
await infomaniakClient.deleteRecord(config, "example.com", "7");
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("DELETE");
});
it("propagates a delete failure", async () => {
mockFetch.mockResolvedValue(ikError("Record not found", 404));
await expect(
infomaniakClient.deleteRecord(config, "example.com", "7"),
).rejects.toThrow("Record not found");
});
});
describe("infomaniakClient.testConnection", () => {
it("resolves when the domain listing succeeds", async () => {
mockFetch.mockResolvedValue(ikSuccess([]));
await expect(
infomaniakClient.testConnection(config),
).resolves.toBeUndefined();
});
it("rejects on an invalid token", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
);
});
});
+581
View File
@@ -0,0 +1,581 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
const textResponse = (body: string, ok = true, status = 200) =>
({
ok,
status,
text: async () => body,
}) as Response;
const ovhSuccess = (data: unknown) =>
textResponse(data === undefined ? "" : JSON.stringify(data));
const ovhError = (message: string, status = 403) =>
textResponse(JSON.stringify({ message }), false, status);
const SERVER_TIME = 1788268225;
const config = {
providerType: "ovh" as const,
endpoint: "ovh-eu" as const,
applicationKey: "app-key",
applicationSecret: "app-secret",
consumerKey: "consumer-key",
};
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
// is keyed by base url, never leaks a measurement between them.
let endpointCursor = 0;
const endpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
const baseUrls: Record<(typeof endpoints)[number], string> = {
"ovh-eu": "https://eu.api.ovh.com/1.0",
"ovh-ca": "https://ca.api.ovh.com/1.0",
"ovh-us": "https://api.us.ovhcloud.com/1.0",
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
};
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
const freshConfig = () => {
const endpoint = endpoints[
endpointCursor % endpoints.length
] as (typeof endpoints)[number];
endpointCursor += 1;
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
};
/** Replies to /auth/time, then to each queued API response in order. */
const mockApi = (...responses: Response[]) => {
let call = 0;
mockFetch.mockImplementation((url: string) => {
if (url.endsWith("/auth/time")) {
return Promise.resolve(textResponse(String(SERVER_TIME)));
}
const response = responses[call];
call += 1;
return Promise.resolve(response ?? ovhSuccess(null));
});
};
const apiCalls = () =>
mockFetch.mock.calls.filter(
([url]) => !(url as string).endsWith("/auth/time"),
) as [string, RequestInit][];
beforeEach(() => {
mockFetch.mockReset();
});
describe("ovhClient request signing", () => {
it("signs the request with the API server clock, not the local one", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess(["example.com"]));
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
await ovhClient.listZones(cfg);
const [url, init] = apiCalls()[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(url).toBe(`${baseUrl}/domain/zone`);
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
expect(headers["X-Ovh-Application"]).toBe("app-key");
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
it("signs a request body when one is sent", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url, init] = apiCalls()[1] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
const body = init.body as string;
expect(body).not.toBe("");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
"+",
),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
});
describe("ovhClient.listZones", () => {
it("maps each zone name to a zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(["example.com", "example.fr"]));
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.fr", name: "example.fr" },
]);
});
it("names the missing root right when OVH refuses the zone listing", async () => {
const cfg = freshConfig();
mockApi(ovhError("This call has not been granted", 403));
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
// so the raw OVH message would send users looking in the wrong place.
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
/missing the `GET \/domain\/zone` right/,
);
});
it("propagates the API error message", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
});
describe("ovhClient.listRecords", () => {
it("resolves each id into a full record", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([1, 2]),
ovhSuccess({
id: 1,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
}),
ovhSuccess({
id: 2,
zone: "example.com",
fieldType: "A",
subDomain: null,
target: "5.6.7.8",
ttl: null,
}),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
{
id: "2",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 0,
},
]);
});
it("keeps the records in the order of the returned ids", async () => {
const cfg = freshConfig();
const record = (id: number, subDomain: string) => ({
id,
zone: "example.com",
fieldType: "A",
subDomain,
target: `10.0.0.${id}`,
ttl: 60,
});
mockApi(
ovhSuccess([1, 2, 3, 4, 5]),
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
});
});
describe("ovhClient.upsertRecord", () => {
it("creates the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "9" });
const calls = apiCalls();
expect(calls[0]?.[0]).toContain(
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
);
expect(calls[1]?.[1].method).toBe("POST");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
});
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
expect(calls[2]?.[1].method).toBe("POST");
});
it("updates the existing record when content matches", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[2]?.[1].method).toBe("PUT");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("creates a new record when content differs from existing", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess({ id: 10 }),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "10" });
const calls = apiCalls();
expect(calls[2]?.[1].method).toBe("POST");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("omits the ttl so OVH applies the zone default", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
"ttl",
);
});
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
});
});
describe("ovhClient.updateRecord", () => {
it("updates in place when the type is unchanged", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("PUT");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
subDomain: "app",
target: "1.2.3.4",
ttl: 300,
});
expect(calls[2]?.[0]).toContain("/refresh");
});
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
});
expect(result).toEqual({ id: "11" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("restores the original record when the replacement fails", async () => {
const cfg = freshConfig();
const original = {
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
};
mockApi(
ovhSuccess(original),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhSuccess({ id: 12 }),
ovhSuccess(null),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow("Invalid target");
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
// The original record is put back with its own type, target and ttl.
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
});
expect(calls[4]?.[0]).toContain("/refresh");
});
it("reports the lost record when the restore also fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhError("Service unavailable", 503),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow(
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
);
});
it("says the change was applied when only the zone refresh fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhError("Service unavailable", 503),
);
// The replacement succeeded, so the record exists at the provider — only
// publishing failed. Rolling back would destroy correct state.
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
}),
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
});
it("does not tell the user to recreate a record that was restored but not published", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null), // DELETE de l'ancien
ovhError("Invalid target", 400), // POST de remplacement -> échec
ovhSuccess({ id: 12 }), // POST de restauration -> succès
ovhError("Service unavailable", 503), // refresh -> échec
);
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
});
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
});
});
describe("ovhClient.deleteRecord", () => {
it("deletes the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(null), ovhSuccess(null));
await ovhClient.deleteRecord(cfg, "example.com", "4");
const calls = apiCalls();
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[0]?.[1].method).toBe("DELETE");
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
});
it("does not refresh the zone when the delete fails", async () => {
const cfg = freshConfig();
mockApi(ovhError("This object does not exist", 404));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "4"),
).rejects.toThrow("This object does not exist");
expect(apiCalls()).toHaveLength(1);
});
});
describe("ovhClient.testConnection", () => {
it("resolves when the zone listing succeeds", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
});
it("rejects on invalid credentials", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
"Invalid signature",
);
});
});
+28 -2
View File
@@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => {
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record instead of creating a duplicate", async () => {
it("edits the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
)
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
@@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => {
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
)
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
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: "5.6.7.8" });
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
@@ -0,0 +1,149 @@
import os from "node:os";
import {
getServerIpCandidates,
validateDomain,
} from "@dokploy/server/services/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
findServerById: vi.fn(),
getPublicIpWithFallback: vi.fn(),
getWebServerSettings: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
}));
vi.mock("node:dns", () => ({
default: {
resolve4: mocks.resolve4,
resolve6: mocks.resolve6,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
}));
describe("getServerIpCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("includes every address reported by a multi-homed remote server", async () => {
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
stderr: "",
});
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
expect.stringContaining("ip -o addr show scope global"),
);
});
it("includes every address assigned to the local Dokploy host", async () => {
mocks.getWebServerSettings.mockResolvedValue({
serverIp: "10.0.0.10",
});
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.0.2.10",
netmask: "255.255.255.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "192.0.2.10/24",
},
],
});
await expect(getServerIpCandidates()).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
});
it("retains remote interface addresses when public IP detection times out", async () => {
vi.useFakeTimers();
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockImplementation(
(_serverId: string, command: string) => {
if (command.includes("curl")) {
return new Promise(() => undefined);
}
return Promise.resolve({
stdout: "192.0.2.10\n",
stderr: "",
});
},
);
const candidatesPromise = getServerIpCandidates("server-id");
await vi.advanceTimersByTimeAsync(7000);
await expect(candidatesPromise).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
]);
});
});
describe("validateDomain", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("validates an IPv6-only domain against an IPv6 server address", async () => {
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
code: "ENODATA",
});
mocks.resolve4.mockImplementation(
(_domain: string, callback: (error: Error | null) => void) =>
callback(noIpv4),
);
mocks.resolve6.mockImplementation(
(
_domain: string,
callback: (error: Error | null, addresses?: string[]) => void,
) => callback(null, ["2001:db8::10"]),
);
await expect(
validateDomain("example.com", ["2001:db8::10"]),
).resolves.toMatchObject({
isValid: true,
resolvedIp: "2001:db8::10",
});
});
});
+195
View File
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: Record<string, unknown> };
const findMany = vi.hoisted(() => vi.fn());
const {
send,
paginate,
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: Record<string, unknown>;
constructor(input: Record<string, unknown>) {
this.input = input;
}
}
const send = vi.fn();
const paginate = vi.fn();
class SSMClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
paginate,
SSMClient,
GetParametersCommand: class extends FakeCommand {},
DescribeParametersCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
paginateDescribeParameters: paginate,
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
const config = {
providerType: "aws-parameter-store" as const,
region: "eu-central-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
paginate.mockReset();
findMany.mockReset();
});
describe("awsParameterStoreClient", () => {
it("decrypts parameters in batches of ten and preserves selectors", async () => {
const refs = [
"/prod/database:CURRENT",
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
];
send.mockImplementation(async (command: HasInput) => ({
Parameters: (command.input.Names as string[]).map((ref) => {
if (ref === "/prod/database:CURRENT") {
return {
Name: "/prod/database",
Selector: ":CURRENT",
Value: "selected-value",
};
}
return { Name: ref, Value: `value-for-${ref}` };
}),
}));
const result = await awsParameterStoreClient.getSecrets(config, refs);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
WithDecryption: true,
});
expect(
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
).toBe(10);
expect(
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
).toBe(1);
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
});
it("reports a missing parameter without exposing other values", async () => {
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
await expect(
awsParameterStoreClient.getSecrets(config, ["/missing"]),
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
});
it("tests the connection within the configured hierarchy", async () => {
send.mockResolvedValue({ Parameters: [] });
await awsParameterStoreClient.testConnection({
...config,
parameterPath: "/production/my-app/",
});
expect(send).toHaveBeenCalledTimes(1);
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
MaxResults: 1,
});
});
it("explains the discovery permission when connection testing is denied", async () => {
const error = new Error("not authorized");
error.name = "AccessDeniedException";
send.mockRejectedValue(error);
await expect(
awsParameterStoreClient.testConnection(config),
).rejects.toThrow("ssm:DescribeParameters");
});
it("lists parameter names across pages within the configured hierarchy", async () => {
paginate.mockReturnValue(
(async function* () {
yield { Parameters: [{ Name: "/prod/db" }] };
yield { Parameters: [{ Name: "/prod/api" }] };
})(),
);
const names = await awsParameterStoreClient.listSecretNames?.({
...config,
parameterPath: " /production/my-app/ ",
});
expect(names).toEqual(["/prod/db", "/prod/api"]);
expect(paginate).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 50 }),
{
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
},
);
});
it("resolves a vault reference through the registered provider", async () => {
findMany.mockResolvedValue([
{
name: "ssm-prod",
providerType: "aws-parameter-store",
config,
assignments: [{ projectId: "project-1", environmentIds: [] }],
},
]);
send.mockResolvedValue({
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
});
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
{
organizationId: "organization-1",
projectId: "project-1",
environmentId: "environment-1",
},
);
expect(result).toBe("DB_PASSWORD=resolved");
});
});
+147
View File
@@ -20,6 +20,7 @@ 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 { infisicalClient } from "@dokploy/server/utils/vault/infisical";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
@@ -431,6 +432,152 @@ describe("azure client", () => {
});
});
describe("infisical client", () => {
const config = {
providerType: "infisical" as const,
siteUrl: "https://app.infisical.com",
clientId: "client-1",
clientSecret: "client-secret",
projectId: "workspace-1",
environmentSlug: "prod",
secretPath: "/frontend",
};
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
const list = (secrets: Record<string, string>) =>
jsonResponse({
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue,
})),
});
const listPathOf = (callIndex: number) => {
const [url] = mockFetch.mock.calls[callIndex] as [string];
return new URL(url).searchParams.get("secretPath");
};
it("asks the list endpoint to expand secret references", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://real" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
const params = new URL(listUrl).searchParams;
expect(params.get("expandSecretReferences")).toBe("true");
expect(params.get("workspaceId")).toBe("workspace-1");
expect(params.get("environment")).toBe("prod");
expect(params.get("secretPath")).toBe("/frontend");
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
await expect(
infisicalClient.getSecrets(config, ["ABSENT"]),
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
});
it("propagates authentication failures with the status code", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
await expect(
infisicalClient.getSecrets(config, ["DB_URL"]),
).rejects.toThrow("authentication failed (status 401)");
});
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
const result = await infisicalClient.getSecrets(config, [
"shared/sentry:SENTRY_DSN",
]);
expect(result).toEqual({
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
});
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
});
it("treats a leading slash as an absolute path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("keeps the root path clean when the provider sits at /", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ KEY: "value" }));
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
"external/sentry:KEY",
]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("logs in once and fetches each path once", async () => {
const byPath: Record<string, Record<string, string>> = {
"/frontend": { A: "a", B: "b" },
"/frontend/other": { C: "c" },
};
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/auth/universal-auth/login")) return loginResponse();
const path = new URL(url).searchParams.get("secretPath") as string;
return list(byPath[path] ?? {});
});
const result = await infisicalClient.getSecrets(config, [
"A",
"B",
"other:C",
]);
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
const urls = mockFetch.mock.calls.map(([url]) => url as string);
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
expect(
urls
.filter((u) => u.includes("/secrets/raw?"))
.map((u) => new URL(u).searchParams.get("secretPath"))
.sort(),
).toEqual(["/frontend", "/frontend/other"]);
});
it("names the path when a secret is missing from an explicit one", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({}));
await expect(
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
).rejects.toThrow(
'secret "ABSENT" not found at "/frontend/external/sentry"',
);
});
it("rejects a ref with an empty path or key", async () => {
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
"expected format <path>:<KEY>",
);
await expect(
infisicalClient.getSecrets(config, ["external/sentry:"]),
).rejects.toThrow("expected format <path>:<KEY>");
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
@@ -0,0 +1,102 @@
import {
buildSandboxContainerOptions,
getSandboxNetworkName,
parseSandboxEnvVars,
parseSandboxUser,
SANDBOX_INTERNET_NETWORK,
SANDBOX_ISOLATED_NETWORK,
} from "@dokploy/server/utils/sandbox/container";
import { describe, expect, it } from "vitest";
const spec = {
sandboxId: "sb_1",
projectId: "proj_1",
environmentId: "env_1",
image: "python:3.12-slim",
cpu: 1.5,
memoryMb: 512,
pidsLimit: 256,
networkMode: "isolated" as const,
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
workdir: "/home/user",
user: "1000:1000",
};
describe("buildSandboxContainerOptions", () => {
it("applies cpu, memory and pids limits", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.HostConfig?.NanoCpus).toBe(1_500_000_000);
expect(options.HostConfig?.Memory).toBe(512 * 1024 * 1024);
expect(options.HostConfig?.MemorySwap).toBe(512 * 1024 * 1024);
expect(options.HostConfig?.PidsLimit).toBe(256);
});
it("hardens the container", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
expect(options.HostConfig?.LogConfig?.Config?.["max-size"]).toBe("10m");
expect(options.User).toBe("1000:1000");
});
it("keeps the container alive with a portable command under an init", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Entrypoint).toEqual([""]);
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
expect(options.HostConfig?.Init).toBe(true);
expect(options.WorkingDir).toBe("/home/user");
});
it("sets the labels used by the reaper and reconcile", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.name).toBe("dokploy-sandbox-sb_1");
expect(options.Labels).toEqual({
"dokploy.sandbox": "true",
"dokploy.sandboxId": "sb_1",
"dokploy.projectId": "proj_1",
"dokploy.environmentId": "env_1",
});
});
it("selects the isolated or internet network and never dokploy-network", () => {
expect(buildSandboxContainerOptions(spec).HostConfig?.NetworkMode).toBe(
SANDBOX_ISOLATED_NETWORK,
);
expect(
buildSandboxContainerOptions({ ...spec, networkMode: "internet" })
.HostConfig?.NetworkMode,
).toBe(SANDBOX_INTERNET_NETWORK);
expect(getSandboxNetworkName("isolated")).not.toBe("dokploy-network");
expect(getSandboxNetworkName("internet")).not.toBe("dokploy-network");
});
it("passes env vars and HOME, skipping comments and malformed lines", () => {
const options = buildSandboxContainerOptions(spec);
expect(options.Env).toEqual(["HOME=/home/user", "FOO=bar", "BAZ=qux=1"]);
});
it("omits User when the image default should be used", () => {
const options = buildSandboxContainerOptions({ ...spec, user: null });
expect(options).not.toHaveProperty("User");
});
});
describe("parseSandboxEnvVars", () => {
it("returns an empty list for empty input", () => {
expect(parseSandboxEnvVars(null)).toEqual([]);
expect(parseSandboxEnvVars("")).toEqual([]);
});
});
describe("parseSandboxUser", () => {
it("parses uid:gid", () => {
expect(parseSandboxUser("1000:1000")).toEqual({ uid: 1000, gid: 1000 });
expect(parseSandboxUser("1001")).toEqual({ uid: 1001, gid: 1001 });
});
it("rejects named users", () => {
expect(parseSandboxUser("node")).toBeNull();
expect(parseSandboxUser(null)).toBeNull();
});
});
+236
View File
@@ -0,0 +1,236 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import {
buildSandboxKillCommand,
runSandboxExec,
SANDBOX_EXEC_MARKER_ENV,
SANDBOX_EXEC_TIMEOUT_EXIT_CODE,
toSandboxEnvArray,
} from "@dokploy/server/utils/sandbox/exec";
import { parse, quote } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// Docker multiplexes stdout/stderr on a non-tty exec as 8-byte frames:
// [stream type, 0, 0, 0, size (BE uint32)] followed by the payload.
const frame = (type: 1 | 2, text: string) => {
const payload = Buffer.from(text);
const header = Buffer.alloc(8);
header[0] = type;
header.writeUInt32BE(payload.length, 4);
return Buffer.concat([header, payload]);
};
// Mirrors docker-modem's demuxStream so the runner is exercised end to end.
const demuxStream = (
stream: NodeJS.ReadableStream,
stdout: NodeJS.WritableStream,
stderr: NodeJS.WritableStream,
) => {
let buffer = Buffer.alloc(0);
stream.on("data", (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 8) {
const size = buffer.readUInt32BE(4);
if (buffer.length < 8 + size) break;
const payload = buffer.subarray(8, 8 + size);
(buffer[0] === 2 ? stderr : stdout).write(payload);
buffer = buffer.subarray(8 + size);
}
});
};
const createDocker = (opts: {
frames: Buffer[];
exitCode: number | null;
endAfterMs?: number;
dieOnKill?: boolean;
}) => {
const stream = new PassThrough();
const execCreate = vi.fn();
const execStart = vi.fn();
const kill = vi.fn(async () => {
stream.end();
});
const start = vi.fn(async () => undefined);
const killExecStart = vi.fn(async () => {
if (opts.dieOnKill) stream.end();
});
const inspect = vi.fn(async () => ({ ExitCode: opts.exitCode }));
const exec = vi.fn(async (options: { Cmd: string[] }) => {
execCreate(options);
if (options.Cmd[2]?.startsWith("if grep -qzF x /dev/null")) {
return { start: killExecStart, inspect };
}
return {
start: vi.fn(async (startOptions: unknown) => {
execStart(startOptions);
setTimeout(() => {
for (const chunk of opts.frames) stream.write(chunk);
if (opts.endAfterMs === undefined) stream.end();
else if (opts.endAfterMs > 0)
setTimeout(() => stream.end(), opts.endAfterMs);
}, 5);
return stream;
}),
inspect,
};
});
const docker = {
getContainer: vi.fn(() => ({ exec, kill, start })),
modem: { demuxStream },
};
return { docker, execCreate, execStart, kill, start, killExecStart, inspect };
};
describe("runSandboxExec", () => {
it("demuxes stdout/stderr and reports the exit code", async () => {
const { docker, execCreate, execStart } = createDocker({
frames: [frame(1, "hello "), frame(1, "world\n"), frame(2, "oops\n")],
exitCode: 3,
});
const stdoutChunks: string[] = [];
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "echo hello world; echo oops >&2; exit 3",
cwd: "/home/user",
env: { FOO: "bar" },
timeoutMs: 5000,
onStdout: (chunk) => stdoutChunks.push(chunk),
});
expect(result).toMatchObject({
stdout: "hello world\n",
stderr: "oops\n",
exitCode: 3,
timedOut: false,
truncated: false,
containerKilled: false,
});
expect(stdoutChunks.join("")).toBe("hello world\n");
const options = execCreate.mock.calls[0]?.[0];
expect(options.Cmd).toEqual([
"sh",
"-c",
"echo hello world; echo oops >&2; exit 3",
]);
expect(options.WorkingDir).toBe("/home/user");
expect(options.Tty).toBe(false);
expect(options.Env).toContain("FOO=bar");
expect(
options.Env.some((e: string) =>
e.startsWith(`${SANDBOX_EXEC_MARKER_ENV}=`),
),
).toBe(true);
expect(execStart).toHaveBeenCalledWith({ hijack: true, stdin: false });
});
it("treats a null exit code as failure", async () => {
const { docker } = createDocker({ frames: [], exitCode: null });
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "true",
timeoutMs: 5000,
});
expect(result.exitCode).toBe(1);
});
it("truncates output beyond maxOutputBytes but keeps streaming", async () => {
const { docker } = createDocker({
frames: [frame(1, "a".repeat(100)), frame(1, "b".repeat(100))],
exitCode: 0,
});
const streamed: string[] = [];
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "yes",
timeoutMs: 5000,
maxOutputBytes: 150,
onStdout: (chunk) => streamed.push(chunk),
});
expect(result.stdout).toHaveLength(150);
expect(result.truncated).toBe(true);
expect(streamed.join("")).toHaveLength(200);
});
it("kills the process on timeout and returns exit code 124", async () => {
const { docker, killExecStart, kill } = createDocker({
frames: [frame(1, "partial")],
exitCode: 0,
endAfterMs: 0,
dieOnKill: true,
});
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
expect(result.stdout).toBe("partial");
expect(killExecStart).toHaveBeenCalledWith({ Detach: true });
expect(kill).not.toHaveBeenCalled();
expect(result.containerKilled).toBe(false);
expect(result.containerRestarted).toBe(false);
});
it("kills and restarts the container when the process survives the marker kill", async () => {
const { docker, kill, start } = createDocker({
frames: [],
exitCode: 0,
endAfterMs: 0,
dieOnKill: false,
});
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
expect(start).toHaveBeenCalledTimes(1);
expect(result.containerKilled).toBe(true);
expect(result.containerRestarted).toBe(true);
}, 10_000);
it("reports a failed restart so the sandbox can be marked as error", async () => {
const { docker, start } = createDocker({
frames: [],
exitCode: 0,
endAfterMs: 0,
dieOnKill: false,
});
start.mockRejectedValueOnce(new Error("no such container"));
const result = await runSandboxExec(docker as never, "ctr", {
cmd: "sleep 100",
timeoutMs: 50,
});
expect(result.containerKilled).toBe(true);
expect(result.containerRestarted).toBe(false);
}, 10_000);
});
describe("buildSandboxKillCommand", () => {
it("matches the marker as a fixed string and never fails the shell", () => {
const cmd = buildSandboxKillCommand("abc-123");
expect(cmd).toContain(`grep -qzxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
expect(cmd).toContain(
`tr '\\0' '\\n' < "$f" 2>/dev/null | grep -qxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`,
);
expect(parse(cmd)).toContain("DOKPLOY_EXEC_ID=abc-123");
expect(cmd).toContain("kill -9");
expect(cmd.endsWith("; true")).toBe(true);
});
});
describe("toSandboxEnvArray", () => {
it("converts records and passes arrays through", () => {
expect(toSandboxEnvArray({ A: "1", B: "x=y" })).toEqual(["A=1", "B=x=y"]);
expect(toSandboxEnvArray(["C=3"])).toEqual(["C=3"]);
expect(toSandboxEnvArray(undefined)).toEqual([]);
});
});
describe("EventEmitter sanity", () => {
it("keeps the fake stream API compatible with what the runner uses", () => {
expect(new PassThrough()).toBeInstanceOf(EventEmitter);
});
});
@@ -0,0 +1,80 @@
import {
buildSandboxFindCommand,
buildSandboxLsCommand,
parseSandboxFindOutput,
parseSandboxLsOutput,
} from "@dokploy/server/utils/sandbox/files";
import { parse } from "shell-quote";
import { describe, expect, it } from "vitest";
describe("list files commands", () => {
it("quotes the path against shell injection", () => {
expect(buildSandboxFindCommand("/tmp/a b; rm -rf /")).toContain(
"'/tmp/a b; rm -rf /'",
);
expect(parse(buildSandboxLsCommand("/tmp/$(id)"))).toEqual([
"ls",
"-1Ap",
"/tmp/$(id)",
]);
});
});
describe("parseSandboxFindOutput", () => {
it("parses type, size, mtime, mode and name, directories first", () => {
const out = [
"f\t12\t1700000000.5000\t644\tmain.py",
"d\t4096\t1700000100.0000\t755\tsrc",
"l\t7\t1700000200.0000\t777\tlink",
"f\t0\t1700000300.0000\t600\tname with\tspaces",
].join("\n");
const entries = parseSandboxFindOutput(out);
expect(entries.map((e) => e.name)).toEqual([
"src",
"link",
"main.py",
"name with\tspaces",
]);
expect(entries[0]).toEqual({
name: "src",
type: "directory",
size: 4096,
mode: "755",
modifiedAt: new Date(1700000100 * 1000).toISOString(),
});
expect(entries[2]).toMatchObject({
type: "file",
size: 12,
modifiedAt: new Date(1700000000500).toISOString(),
});
expect(entries[1]?.type).toBe("symlink");
});
it("returns an empty list for an empty directory", () => {
expect(parseSandboxFindOutput("")).toEqual([]);
expect(parseSandboxFindOutput("\n")).toEqual([]);
});
});
describe("parseSandboxLsOutput", () => {
it("marks trailing-slash entries as directories", () => {
const entries = parseSandboxLsOutput("b.txt\na/\n.hidden\n");
expect(entries).toEqual([
{
name: "a",
type: "directory",
size: null,
mode: null,
modifiedAt: null,
},
{
name: ".hidden",
type: "file",
size: null,
mode: null,
modifiedAt: null,
},
{ name: "b.txt", type: "file", size: null, mode: null, modifiedAt: null },
]);
});
});
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: vi.fn(),
}));
import {
ensureSandboxNetwork,
SANDBOX_NETWORK_OPTIONS,
} from "@dokploy/server/services/sandbox";
const notFound = () =>
Object.assign(new Error("no such network"), { statusCode: 404 });
const createDocker = (inspect: () => Promise<unknown>) => {
const remove = vi.fn(async () => undefined);
const createNetwork = vi.fn(async () => undefined);
const getNetwork = vi.fn(() => ({ inspect, remove }));
return {
docker: { getNetwork, createNetwork },
remove,
createNetwork,
getNetwork,
};
};
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});
describe("ensureSandboxNetwork", () => {
it("creates an internal bridge without inter-container traffic for isolated mode", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
const name = await ensureSandboxNetwork(docker as never, "isolated");
expect(name).toBe("dokploy-sandboxes-isolated");
expect(createNetwork).toHaveBeenCalledWith(
expect.objectContaining({
Name: "dokploy-sandboxes-isolated",
Driver: "bridge",
Internal: true,
Options: SANDBOX_NETWORK_OPTIONS,
}),
);
expect(
SANDBOX_NETWORK_OPTIONS["com.docker.network.bridge.enable_icc"],
).toBe("false");
});
it("creates a non-internal bridge for internet mode", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
await ensureSandboxNetwork(docker as never, "internet");
expect(createNetwork).toHaveBeenCalledWith(
expect.objectContaining({
Name: "dokploy-sandboxes",
Internal: false,
Options: SANDBOX_NETWORK_OPTIONS,
}),
);
});
it("reuses a network that already blocks inter-container traffic", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: SANDBOX_NETWORK_OPTIONS,
Containers: {},
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(createNetwork).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
it("recreates an unused legacy network that allows inter-container traffic", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: {},
Containers: {},
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(remove).toHaveBeenCalledTimes(1);
expect(createNetwork).toHaveBeenCalledTimes(1);
});
it("keeps a legacy network that still has containers attached and warns", async () => {
const { docker, createNetwork, remove } = createDocker(async () => ({
Options: {},
Containers: { abc: { Name: "dokploy-sandbox-x" } },
}));
await ensureSandboxNetwork(docker as never, "isolated");
expect(remove).not.toHaveBeenCalled();
expect(createNetwork).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalled();
});
it("tolerates a concurrent create of the same network", async () => {
const { docker, createNetwork } = createDocker(async () => {
throw notFound();
});
createNetwork.mockRejectedValueOnce(
Object.assign(new Error("exists"), { statusCode: 409 }),
);
await expect(
ensureSandboxNetwork(docker as never, "isolated"),
).resolves.toBe("dokploy-sandboxes-isolated");
});
});
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/db", () => ({
db: { query: { sandboxes: { findMany } } },
}));
vi.mock("@dokploy/server/services/sandbox", () => ({
killSandbox: vi.fn(),
}));
import {
findExpiredSandboxes,
planSandboxReconcile,
reapExpiredSandboxes,
} from "@dokploy/server/utils/sandbox/reaper";
const now = new Date("2026-09-10T12:00:00Z");
const past = new Date(now.getTime() - 1000);
const future = new Date(now.getTime() + 60_000);
beforeEach(() => {
findMany.mockReset();
});
describe("findExpiredSandboxes", () => {
it("returns only running sandboxes whose expiresAt has passed", () => {
const list = [
{ sandboxId: "expired", status: "running", expiresAt: past },
{ sandboxId: "alive", status: "running", expiresAt: future },
{ sandboxId: "no-expiry", status: "running", expiresAt: null },
{ sandboxId: "killed", status: "killed", expiresAt: past },
{ sandboxId: "error", status: "error", expiresAt: past },
];
expect(findExpiredSandboxes(list, now).map((s) => s.sandboxId)).toEqual([
"expired",
]);
});
});
describe("reapExpiredSandboxes", () => {
it("kills expired sandboxes and leaves the rest untouched", async () => {
findMany.mockResolvedValue([
{ sandboxId: "expired", status: "running", expiresAt: past },
{ sandboxId: "alive", status: "running", expiresAt: future },
]);
const kill = vi.fn(async () => undefined);
const killed = await reapExpiredSandboxes(now, kill);
expect(killed).toEqual(["expired"]);
expect(kill).toHaveBeenCalledTimes(1);
expect(kill).toHaveBeenCalledWith("expired");
});
it("continues when one kill fails", async () => {
findMany.mockResolvedValue([
{ sandboxId: "a", status: "running", expiresAt: past },
{ sandboxId: "b", status: "running", expiresAt: past },
]);
const kill = vi.fn(async (id: string) => {
if (id === "a") throw new Error("docker down");
});
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const killed = await reapExpiredSandboxes(now, kill);
spy.mockRestore();
expect(killed).toEqual(["b"]);
expect(kill).toHaveBeenCalledTimes(2);
});
});
describe("planSandboxReconcile", () => {
const container = (id: string, sandboxId: string, state = "running") => ({
Id: id,
State: state,
Labels: { "dokploy.sandbox": "true", "dokploy.sandboxId": sandboxId },
});
it("removes containers without a running row and errors rows without a container", () => {
const plan = planSandboxReconcile(
[
{ sandboxId: "ok", containerId: "c-ok", status: "running" },
{ sandboxId: "gone", containerId: "c-gone", status: "running" },
{ sandboxId: "never-started", containerId: null, status: "running" },
],
[
container("c-ok", "ok"),
container("c-orphan", "orphan"),
container("c-exited", "ok-old", "exited"),
],
);
expect(plan.removeContainers).toEqual(["c-orphan", "c-exited"]);
expect(plan.markError).toEqual(["gone", "never-started"]);
});
it("removes a stopped container even when its row is running, and errors the row", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
[container("c1", "sb", "exited")],
);
expect(plan.removeContainers).toEqual(["c1"]);
expect(plan.markError).toEqual(["sb"]);
});
it("leaves containers of sandboxes that are still being created alone", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "new", containerId: null, status: "creating" }],
[container("c-new", "new")],
);
expect(plan).toEqual({ removeContainers: [], markError: [] });
});
it("does nothing when everything matches", () => {
const plan = planSandboxReconcile(
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
[container("c1", "sb")],
);
expect(plan).toEqual({ removeContainers: [], markError: [] });
});
});
+145
View File
@@ -0,0 +1,145 @@
import { Readable } from "node:stream";
import {
buildSandboxDirectoryTar,
buildSandboxFileTar,
extractSandboxFile,
SandboxFileTooLargeError,
SandboxNotRegularFileError,
} from "@dokploy/server/utils/sandbox/tar";
import { describe, expect, it } from "vitest";
type Entry = {
name: string;
type: string;
mode: number;
uid: number;
gid: number;
size: number;
content: string;
};
const TYPES: Record<string, string> = {
"0": "file",
"\0": "file",
"5": "directory",
};
// Minimal ustar reader: 512-byte header blocks with octal numeric fields.
const readTar = (buffer: Buffer): Entry[] => {
const entries: Entry[] = [];
let offset = 0;
const field = (start: number, length: number) =>
buffer
.subarray(offset + start, offset + start + length)
.toString("utf8")
.replace(/\0.*$/s, "");
const octal = (start: number, length: number) =>
Number.parseInt(field(start, length).trim() || "0", 8);
while (offset + 512 <= buffer.length) {
const name = field(0, 100);
if (!name) break;
const size = octal(124, 12);
const typeflag = buffer[offset + 156] ?? 0;
entries.push({
name,
type: TYPES[String.fromCharCode(typeflag)] ?? "other",
mode: octal(100, 8),
uid: octal(108, 8),
gid: octal(116, 8),
size,
content: buffer
.subarray(offset + 512, offset + 512 + size)
.toString("utf8"),
});
offset += 512 + Math.ceil(size / 512) * 512;
}
return entries;
};
describe("buildSandboxFileTar", () => {
it("packs a single file with the requested ownership and mode", async () => {
const tar = await buildSandboxFileTar({
name: "main.py",
content: Buffer.from("print('hi')\n"),
uid: 1000,
gid: 1000,
mode: 0o600,
});
const entries = await readTar(tar);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "main.py",
type: "file",
mode: 0o600,
uid: 1000,
gid: 1000,
size: 12,
content: "print('hi')\n",
});
});
it("defaults to root ownership and 0644", async () => {
const entries = await readTar(
await buildSandboxFileTar({ name: "a.txt", content: Buffer.alloc(0) }),
);
expect(entries[0]).toMatchObject({ mode: 0o644, uid: 0, gid: 0, size: 0 });
});
});
describe("buildSandboxDirectoryTar", () => {
it("packs a directory entry with a trailing slash", async () => {
const entries = await readTar(
await buildSandboxDirectoryTar({ name: "user", uid: 1000, gid: 1000 }),
);
expect(entries[0]).toMatchObject({
name: "user/",
type: "directory",
mode: 0o755,
uid: 1000,
gid: 1000,
});
});
});
describe("extractSandboxFile", () => {
it("extracts the first regular file", async () => {
const tar = await buildSandboxFileTar({
name: "out.txt",
content: Buffer.from("hello"),
});
const file = await extractSandboxFile(Readable.from(tar), 1024);
expect(file.name).toBe("out.txt");
expect(file.content.toString()).toBe("hello");
});
it("rejects files over the limit", async () => {
const tar = await buildSandboxFileTar({
name: "big.bin",
content: Buffer.alloc(2048, 1),
});
await expect(
extractSandboxFile(Readable.from(tar), 1024),
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
});
it("rejects a directory archive even when it contains files", async () => {
const dir = await buildSandboxDirectoryTar({ name: "proj" });
const file = await buildSandboxFileTar({
name: "proj/main.py",
content: Buffer.from("print(1)"),
});
// tar-stream pads each archive with two zero blocks; strip the first
// archive's end-of-archive marker so the entries are read as one tar.
const combined = Buffer.concat([dir.subarray(0, dir.length - 1024), file]);
await expect(
extractSandboxFile(Readable.from(combined), 1024),
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
});
it("rejects archives without a regular file", async () => {
const tar = await buildSandboxDirectoryTar({ name: "dir" });
await expect(
extractSandboxFile(Readable.from(tar), 1024),
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
});
});
@@ -154,7 +154,16 @@ const hasRealMonitoring = () => {
);
};
describe.skipIf(hasRealMonitoring())(
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 () => {
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
ogImageUrl: null,
footerText: null,
},
cleanupCacheApplications: false,
@@ -0,0 +1,60 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
}));
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
describe("pipeBetweenServers", () => {
it("delivers a short source stream that ends before the target is ready", async () => {
const bytes = await pipeBetweenServers({
source: { serverId: null, command: "printf 'hello world'" },
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
});
it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
const source = path.join(dir, "src.bin");
const target = path.join(dir, "dst.bin");
const size = 1024 * 1024;
const progress: number[] = [];
const bytes = await pipeBetweenServers({
source: {
serverId: null,
command: `head -c ${size} /dev/urandom | tee '${source}'`,
},
target: { serverId: null, command: `cat > '${target}'` },
onProgress: (transferred) => progress.push(transferred),
});
expect(bytes).toBe(size);
expect(progress.at(-1)).toBe(size);
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
await fs.rm(dir, { recursive: true, force: true });
}, 30_000);
it("reports a target failure with its stderr", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
it("reports a source failure", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
});
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";
@@ -1,31 +0,0 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainId,
},
{
enabled: !!domainId,
enabled: isOpen && !!domainId,
},
);
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
applicationId: id,
},
{
enabled: !!id,
enabled: isOpen && !!id,
},
)
: api.compose.one.useQuery(
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
},
{
enabled: !!id,
enabled: isOpen && !!id,
},
);
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
api.domain.canGenerateTraefikMeDomains.useQuery(
{
serverId: application?.serverId || "",
},
{
enabled: isOpen,
},
);
const {
data: services,
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
{
retry: false,
refetchOnWindowFocus: false,
enabled: type === "compose" && !!id,
enabled: isOpen && type === "compose" && !!id,
},
);
@@ -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];
@@ -0,0 +1,34 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
export const TrialBanner = () => {
const router = useRouter();
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
if (!billingStatus?.isOnTrial) {
return null;
}
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
return (
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
<span className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-primary shrink-0" />
{daysRemaining > 0
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
: "Your free trial ends today."}
</span>
<Button
size="sm"
variant="default"
className="h-7"
onClick={() => router.push("/dashboard/settings/billing")}
>
Upgrade now
</Button>
</div>
);
};
@@ -22,6 +22,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
@@ -30,6 +31,7 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
command: data.command || "",
pullImages: data.pullImages ?? false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddCommand) => {
const onSubmit = async (formData: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
command: formData.command,
pullImages: formData.pullImages,
})
.then(async () => {
toast.success("Command Updated");
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
{data?.composeType === "docker-compose" && (
<FormField
control={form.control}
name="pullImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="space-y-0.5">
<FormLabel>Pull latest images on deploy</FormLabel>
<FormDescription>
Adds <strong>--pull always</strong> to the default
command so every deploy fetches the newest image for
each tag. Has no effect when a custom command is set.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="command"
@@ -0,0 +1,216 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const freshVolumesSchema = z.object({
confirmName: z.string().min(1, {
message: "Compose name is required",
}),
});
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
interface Props {
composeId: string;
}
export const FreshVolumes = ({ composeId }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
const form = useForm<FreshVolumesForm>({
defaultValues: { confirmName: "" },
resolver: zodResolver(freshVolumesSchema),
});
const expectedName = `${data?.name}/${data?.appName}`;
const isRunning = data?.composeStatus === "running";
const onSubmit = async (formData: FreshVolumesForm) => {
if (formData.confirmName !== expectedName) {
form.setError("confirmName", {
message: `Compose name must match "${expectedName}"`,
});
return;
}
await deploy({ composeId, freshVolumes: true })
.then(() => {
toast.success("Compose deployed with fresh volumes");
setIsOpen(false);
form.reset();
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
};
if (!canDeploy || data?.composeType !== "docker-compose") return null;
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">
Deploy with Fresh Volumes
</h3>
<p className="text-sm text-muted-foreground">
Permanently wipes every volume of this compose and redeploys it
from a clean state. All persistent data (databases, uploads,
caches) will be lost.
</p>
</div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) form.reset();
}}
>
<DialogTrigger asChild>
<Button
isLoading={isPending}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<HardDriveDownload className="mr-2 h-4 w-4" />
Deploy with Fresh Volumes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current compose</li>
<li>Delete all volumes and their data</li>
<li>Redeploy the compose with empty volumes</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-fresh-volumes"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="confirmName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(expectedName);
toast.success(
"Copied to clipboard. Be careful!",
);
}
}}
>
{expectedName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{isRunning && (
<AlertBlock type="warning" className="w-full">
Cannot deploy with fresh volumes while a deployment is
running. Please wait for it to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
isLoading={isPending}
disabled={isRunning}
form="hook-form-fresh-volumes"
type="submit"
variant="destructive"
>
Wipe volumes and deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
};
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -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 7-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`}>
7-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">
{[
"Setup 1 server",
"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">
{[
"Setup 1 server",
"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">
{[
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"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,9 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { 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";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,6 +16,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@@ -24,6 +27,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, {
@@ -37,10 +42,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(
{
@@ -65,14 +84,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 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
})
.then(() => {
form.reset();
setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@@ -99,8 +123,94 @@ 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 ? (
<Button
@@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormItem className="items-center gap-4">
<div className="flex items-center justify-between">
<FormLabel className="text-right">Name</FormLabel>
</div>
<FormControl>
<Input
placeholder="Organization name"
@@ -162,23 +274,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 overflow-hidden">
{field.value ? (
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
<img
src={field.value}
alt="Logo preview"
className="size-full object-cover"
/>
) : (
<Logo className="size-7" />
)}
</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>
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -0,0 +1,383 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Box, PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const schema = z
.object({
name: z.string().max(64).optional(),
source: z.enum(["template", "image"]),
template: z.enum(["base", "python", "node"]),
image: z.string().max(255).optional(),
serverId: z.string().nullable(),
cpu: z.number().min(0.1).max(64),
memoryMb: z.number().int().min(64).max(262_144),
timeoutMinutes: z.number().min(1).max(1440),
networkMode: z.enum(["isolated", "internet"]),
envVars: z.string().optional(),
})
.refine((value) => value.source === "template" || !!value.image?.trim(), {
message: "Image is required",
path: ["image"],
});
type FormValues = z.infer<typeof schema>;
interface Props {
environmentId: string;
projectId: string;
}
export const AddSandbox = ({ environmentId, projectId }: Props) => {
const router = useRouter();
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: templates } = api.sandbox.templates.useQuery();
const { mutateAsync, isPending, error } = api.sandbox.create.useMutation();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const form = useForm<FormValues>({
defaultValues: {
name: "",
source: "template",
template: "python",
image: "",
serverId: null,
cpu: 1,
memoryMb: 512,
timeoutMinutes: 5,
networkMode: "isolated",
envVars: "",
},
resolver: zodResolver(schema),
});
const source = form.watch("source");
const onSubmit = async (values: FormValues) => {
try {
const sandbox = await mutateAsync({
environmentId,
name: values.name?.trim() || undefined,
template: values.source === "template" ? values.template : undefined,
image: values.source === "image" ? values.image?.trim() : undefined,
serverId: values.serverId,
cpu: values.cpu,
memoryMb: values.memoryMb,
timeoutMs: Math.round(values.timeoutMinutes * 60_000),
networkMode: values.networkMode,
envVars: values.envVars?.trim() || undefined,
});
toast.success("Sandbox created");
await utils.sandbox.list.invalidate({ environmentId });
setOpen(false);
form.reset();
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
);
} catch {
toast.error("Error creating the sandbox");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="size-4" />
Create Sandbox
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl md:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Box className="size-5 text-muted-foreground" />
New Sandbox
</DialogTitle>
<DialogDescription>
An ephemeral, resource-limited container you can drive through the
API. It is removed automatically when its timeout expires.
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
<Form {...form}>
<form
id="add-sandbox-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-sandbox" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Image</FormLabel>
<Tabs value={field.value} onValueChange={field.onChange}>
<TabsList>
<TabsTrigger value="template">Template</TabsTrigger>
<TabsTrigger value="image">Custom image</TabsTrigger>
</TabsList>
</Tabs>
</FormItem>
)}
/>
{source === "template" ? (
<FormField
control={form.control}
name="template"
render={({ field }) => (
<FormItem>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{(templates ?? []).map((template) => (
<SelectItem key={template.name} value={template.name}>
<span className="capitalize">{template.name}</span>
<span className="text-muted-foreground">
{" "}
· {template.image}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="alpine:3.20" {...field} />
</FormControl>
<FormDescription>
The image must contain a POSIX shell (sh) and tail.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{(servers?.length ?? 0) > 0 && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
value={field.value ?? "dokploy"}
onValueChange={(value) =>
field.onChange(value === "dokploy" ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
{showLocalOption && (
<SelectItem value="dokploy">
Dokploy Server
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<FormField
control={form.control}
name="cpu"
render={({ field }) => (
<FormItem>
<FormLabel>CPU cores</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={0.1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryMb"
render={({ field }) => (
<FormItem>
<FormLabel>Memory (MB)</FormLabel>
<FormControl>
<Input
type="number"
step="64"
min={64}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeoutMinutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
step="1"
min={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="networkMode"
render={({ field }) => (
<FormItem>
<FormLabel>Network</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">
Isolated (no network access)
</SelectItem>
<SelectItem value="internet">Internet access</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Sandboxes are never attached to dokploy-network or any
application network.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<Textarea
placeholder={"KEY=value\nOTHER=value"}
className="font-mono min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="add-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,120 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Clock } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const schema = z.object({
minutes: z.number().min(1).max(1440),
});
interface Props {
sandboxId: string;
currentTimeoutMs: number;
}
export const ExtendSandboxTimeout = ({
sandboxId,
currentTimeoutMs,
}: Props) => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isPending } = api.sandbox.setTimeout.useMutation();
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
minutes: Math.max(1, Math.round(currentTimeoutMs / 60_000)),
},
resolver: zodResolver(schema),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Clock className="size-4" />
Extend timeout
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Extend timeout</DialogTitle>
<DialogDescription>
The sandbox will be killed this many minutes after its last
activity.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="extend-sandbox-form"
className="grid gap-4"
onSubmit={form.handleSubmit(async (values) => {
await mutateAsync({
sandboxId,
timeoutMs: Math.round(values.minutes * 60_000),
})
.then(async () => {
toast.success("Timeout updated");
await utils.sandbox.one.invalidate({ sandboxId });
setOpen(false);
})
.catch(() => toast.error("Error updating the timeout"));
})}
>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
step={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="extend-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,185 @@
import { Eraser, Loader2, Play, Terminal } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Line = {
kind: "cmd" | "stdout" | "stderr" | "exit" | "error" | "info";
text: string;
};
type ServerMessage =
| { type: "ready"; sandboxId: string }
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "exit"; exitCode: number; timedOut: boolean }
| { type: "error"; message: string };
interface Props {
sandboxId: string;
enabled: boolean;
workdir: string;
}
const lineClass: Record<Line["kind"], string> = {
cmd: "text-primary font-semibold",
stdout: "text-foreground",
stderr: "text-orange-500",
exit: "text-muted-foreground italic",
error: "text-destructive",
info: "text-muted-foreground",
};
export const SandboxConsole = ({ sandboxId, enabled, workdir }: Props) => {
const [lines, setLines] = useState<Line[]>([]);
const [command, setCommand] = useState("");
const [running, setRunning] = useState(false);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const outputRef = useRef<HTMLDivElement | null>(null);
const append = useCallback((line: Line) => {
setLines((prev) => {
const last = prev[prev.length - 1];
if (
last &&
(line.kind === "stdout" || line.kind === "stderr") &&
last.kind === line.kind &&
!last.text.endsWith("\n")
) {
return [...prev.slice(0, -1), { ...last, text: last.text + line.text }];
}
return [...prev, line];
});
}, []);
useEffect(() => {
if (!enabled) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${protocol}//${window.location.host}/sandbox-exec?sandboxId=${sandboxId}`,
);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = (event) => {
setConnected(false);
setRunning(false);
if (event.code >= 4000) {
append({ kind: "error", text: event.reason || "Connection closed" });
}
};
ws.onmessage = (event) => {
let message: ServerMessage;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "stdout")
append({ kind: "stdout", text: message.data });
else if (message.type === "stderr")
append({ kind: "stderr", text: message.data });
else if (message.type === "exit") {
append({
kind: "exit",
text: message.timedOut
? `[timed out, exit code ${message.exitCode}]`
: `[exit code ${message.exitCode}]`,
});
setRunning(false);
} else if (message.type === "error") {
append({ kind: "error", text: message.message });
setRunning(false);
}
};
return () => {
ws.close();
wsRef.current = null;
};
}, [sandboxId, enabled, append]);
useEffect(() => {
outputRef.current?.scrollTo({ top: outputRef.current.scrollHeight });
}, [lines]);
const run = () => {
const cmd = command.trim();
const ws = wsRef.current;
if (!cmd || !ws || ws.readyState !== WebSocket.OPEN || running) return;
append({ kind: "cmd", text: `$ ${cmd}` });
setRunning(true);
setCommand("");
ws.send(JSON.stringify({ type: "exec", cmd }));
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
Console
<span className="text-xs font-normal text-muted-foreground">
{enabled
? connected
? "connected"
: "connecting..."
: "sandbox is not running"}
</span>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
<Eraser className="size-4" />
Clear
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div
ref={outputRef}
className="h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs whitespace-pre-wrap break-words"
>
{lines.length === 0 ? (
<span className="text-muted-foreground">
Commands run with `sh -c` inside {workdir}. Output streams here.
</span>
) : (
lines.map((line, index) => (
<div key={index} className={cn(lineClass[line.kind])}>
{line.text}
</div>
))
)}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
run();
}}
>
<Input
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="echo hello"
className="font-mono"
disabled={!enabled || !connected || running}
autoComplete="off"
/>
<Button type="submit" disabled={!enabled || !connected || running}>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,16 @@
import { Badge } from "@/components/ui/badge";
type SandboxStatus = "creating" | "running" | "killed" | "error";
const variants: Record<SandboxStatus, "green" | "yellow" | "blank" | "red"> = {
running: "green",
creating: "yellow",
killed: "blank",
error: "red",
};
export const SandboxStatusBadge = ({ status }: { status: SandboxStatus }) => (
<Badge variant={variants[status]} className="capitalize">
{status}
</Badge>
);
@@ -0,0 +1,134 @@
import { formatDistanceToNow } from "date-fns";
import { Box, ServerIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { SandboxStatusBadge } from "./sandbox-status-badge";
interface Props {
projectId: string;
environmentId: string;
}
export const formatExpiresIn = (expiresAt: Date | null, status: string) => {
if (status !== "running" || !expiresAt) return "—";
if (expiresAt.getTime() <= Date.now()) return "expired";
return formatDistanceToNow(expiresAt, { addSuffix: true });
};
export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
{ environmentId },
{ refetchInterval: 10_000 },
);
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!sandboxes || sandboxes.length === 0) {
return (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-2">
<Box className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No sandboxes yet. Create one from the UI or through the API.
</span>
</div>
);
}
return (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>CPU / RAM</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sandboxes.map((sandbox) => (
<TableRow key={sandbox.sandboxId}>
<TableCell className="font-medium">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
className="hover:underline"
>
{sandbox.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">
{sandbox.image}
</TableCell>
<TableCell>
<SandboxStatusBadge status={sandbox.status} />
</TableCell>
<TableCell>
<span className="flex items-center gap-1.5 text-sm">
<ServerIcon className="size-3.5 text-muted-foreground" />
{sandbox.server?.name ?? "Dokploy Server"}
</span>
</TableCell>
<TableCell className="text-sm">
{sandbox.cpu} vCPU / {sandbox.memoryMb} MB
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatExpiresIn(sandbox.expiresAt, sandbox.status)}
</TableCell>
<TableCell className="text-sm">
<DateTooltip date={sandbox.createdAt} />
</TableCell>
<TableCell className="text-right">
{sandbox.status === "running" &&
permissions?.deployment.create && (
<DialogAction
title="Kill sandbox"
description={`The container for "${sandbox.name}" will be stopped and removed.`}
onClick={async () => {
await kill({ sandboxId: sandbox.sandboxId })
.then(() => {
toast.success("Sandbox killed");
utils.sandbox.list.invalidate({ environmentId });
})
.catch(() =>
toast.error("Error killing the sandbox"),
);
}}
>
<Button variant="ghost" size="icon">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
@@ -24,7 +24,7 @@ export const AiForm = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -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 7-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">
7-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">
{[
"Setup 1 server",
"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>
@@ -809,7 +917,7 @@ export const ShowBilling = () => {
"Unlimited Deployments",
"Unlimited Databases",
"Unlimited Applications",
"1 Server Included",
"Setup 1 Server",
"1 Organization",
"1 User",
"2 Environments",
@@ -941,7 +1049,7 @@ export const ShowBilling = () => {
All the features of Hobby, plus…
</li>
{[
"3 Servers Included",
"Setup up to 3 Servers",
"3 Organizations",
"Unlimited Users",
"Unlimited Environments",
@@ -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>
</>
);
};
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -44,6 +44,18 @@ const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
} as const;
const ovhEndpointLabels = {
"ovh-eu": "OVHcloud Europe",
"ovh-ca": "OVHcloud Canada",
"ovh-us": "OVHcloud US",
"kimsufi-eu": "Kimsufi Europe",
"kimsufi-ca": "Kimsufi Canada",
"soyoustart-eu": "So you Start Europe",
"soyoustart-ca": "So you Start Canada",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -55,12 +67,27 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
providerType: z.enum([
"cloudflare",
"route53",
"porkbun",
"infomaniak",
"ovh",
]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
endpoint: z.enum(
Object.keys(ovhEndpointLabels) as [
keyof typeof ovhEndpointLabels,
...(keyof typeof ovhEndpointLabels)[],
],
),
applicationKey: z.string(),
applicationSecret: z.string(),
consumerKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -73,6 +100,10 @@ const defaultValues: DnsProviderForm = {
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
endpoint: "ovh-eu",
applicationKey: "",
applicationSecret: "",
consumerKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -94,6 +125,19 @@ const buildConfig = (data: DnsProviderForm) => {
apiKey: data.apiKey,
secretApiKey: data.secretApiKey,
};
case "infomaniak":
return {
providerType: "infomaniak" as const,
apiToken: data.apiToken,
};
case "ovh":
return {
providerType: "ovh" as const,
endpoint: data.endpoint,
applicationKey: data.applicationKey,
applicationSecret: data.applicationSecret,
consumerKey: data.consumerKey,
};
}
};
@@ -155,6 +199,15 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
apiKey: provider.config.apiKey,
secretApiKey: provider.config.secretApiKey,
}),
...(provider.config.providerType === "infomaniak" && {
apiToken: provider.config.apiToken,
}),
...(provider.config.providerType === "ovh" && {
endpoint: provider.config.endpoint,
applicationKey: provider.config.applicationKey,
applicationSecret: provider.config.applicationSecret,
consumerKey: provider.config.consumerKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@@ -383,6 +436,118 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</>
)}
{providerType === "infomaniak" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token at manager.infomaniak.com with the{" "}
<code>domain:read</code>, <code>dns:read</code> and{" "}
<code>dns:write</code> scopes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "ovh" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>API Endpoint</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(ovhEndpointLabels).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationKey"
render={({ field }) => (
<FormItem>
<FormLabel>Application Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Application Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consumerKey"
render={({ field }) => (
<FormItem>
<FormLabel>Consumer Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create the three keys at once on
api.ovh.com/createToken, with exactly these five rights:
<br />
<code>GET /domain/zone</code>
<br />
<code>GET /domain/zone/*</code>
<br />
<code>POST /domain/zone/*</code>
<br />
<code>PUT /domain/zone/*</code>
<br />
<code>DELETE /domain/zone/*</code>
<br />
The first one lists your zones and has to be granted on
its own: OVH matches rights per exact path, so{" "}
<code>/domain/zone/*</code> does not cover it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
@@ -23,6 +23,9 @@ import { HandleDnsProvider } from "./handle-dns-provider";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
};
export const ShowDnsProviders = () => {
@@ -32,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,28 +0,0 @@
import { Stethoscope } from "lucide-react";
import { useState } from "react";
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
interface Props {
serverId: string;
}
export const ShowHealthModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Stethoscope className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
<div className="flex gap-4 py-4 w-full">
<ShowHealth serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};
@@ -33,7 +33,6 @@ import { ShowServerActions } from "./actions/show-server-actions";
import { DeleteServerModal } from "./delete-server-modal";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowHealthModal } from "./show-health-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
@@ -50,7 +49,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -116,10 +115,13 @@ export const ShowServers = () => {
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-lg wrap-break-word min-w-0">
<CardTitle
className="text-lg truncate min-w-0"
title={server.name}
>
{server.name}
</CardTitle>
</div>
@@ -322,24 +324,6 @@ export const ShowServers = () => {
</Tooltip>
)}
{permissions?.docker.read &&
permissions?.server.read &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowHealthModal
serverId={server.serverId}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Health</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
{permissions?.server.delete && (
@@ -1,281 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DialogFooter } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
ipAddress: z.string().min(1, {
message: "IP Address is required",
}),
port: z.number().optional(),
username: z.string().optional(),
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
});
type Schema = z.infer<typeof Schema>;
interface Props {
stepper: any;
}
export const CreateServer = ({ stepper }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const [isOpen, _setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
api.stripe.canCreateMoreServers.useQuery();
const { mutateAsync } = api.server.create.useMutation();
const cloudSSHKey = sshKeys?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
const form = useForm<Schema>({
defaultValues: {
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
},
resolver: zodResolver(Schema),
});
useEffect(() => {
form.reset({
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
useEffect(() => {
refetch();
}, [isOpen]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
name: data.name,
description: data.description || "",
ipAddress: data.ipAddress?.trim() || "",
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");
stepper.next();
})
.catch(() => {
toast.error("Error creating a server");
});
};
return (
<Card className="bg-background flex flex-col gap-4">
<div className="flex flex-col gap-2 pt-5 px-4">
{!canCreateMoreServers && (
<AlertBlock type="warning" className="mt-2">
You cannot create more servers,{" "}
<Link href="/dashboard/settings/billing" className="text-primary">
Please upgrade your plan
</Link>
</AlertBlock>
)}
</div>
<CardContent className="flex flex-col">
<Form {...form}>
<form
id="hook-form-add-server"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4 ">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Hostinger Server" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="This server is for databases..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a SSH Key</FormLabel>
{!cloudSSHKey && (
<AlertBlock>
Looks like you didn't have the SSH Key yet, you can create
one{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
>
here
</Link>
</AlertBlock>
)}
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a SSH Key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="22"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
disabled={!canCreateMoreServers}
form="hook-form-add-server"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</CardContent>
</Card>
);
};
@@ -1,190 +0,0 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, Loader2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api";
export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery();
const generateMutation = api.sshKey.generate.useMutation();
const { mutateAsync, isPending } = api.sshKey.create.useMutation();
const hasCreatedKey = useRef(false);
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
"manual",
);
const cloudSSHKey = data?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
useEffect(() => {
const createKey = async () => {
if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
return;
}
hasCreatedKey.current = true;
try {
const keys = await generateMutation.mutateAsync({
type: "rsa",
});
await mutateAsync({
name: "dokploy-cloud-ssh-key",
description: "Used on Dokploy Cloud",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
organizationId: "",
});
await refetch();
} catch (error) {
console.error("Error creating SSH key:", error);
hasCreatedKey.current = false;
}
};
createKey();
}, [data]);
return (
<Card className="h-full bg-transparent">
<CardContent>
<div className="grid w-full gap-4 pt-4">
{isPending || !cloudSSHKey ? (
<div className="min-h-[25vh] justify-center flex items-center gap-4">
<Loader2
className="animate-spin text-muted-foreground"
size={32}
/>
</div>
) : (
<>
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
<p className="text-primary text-base font-semibold">
Choose how to add SSH Keys to your server:
</p>
{/* Radio button options */}
<div className="grid gap-2">
<RadioGroup
value={selectedOption}
onValueChange={(value) => {
setSelectedOption(value as "manual" | "provider");
}}
className="grid gap-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manual" id="manual" />
<Label
htmlFor="manual"
className="text-primary font-medium cursor-pointer"
>
Add SSH Key to Server Manually
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="provider" id="provider" />
<Label
htmlFor="provider"
className="text-primary font-medium cursor-pointer"
>
Add SSH Key when creating server in your provider
</Label>
</div>
</RadioGroup>
</div>
{/* Content based on selected option */}
{selectedOption === "manual" && (
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Manual Setup Instructions
</span>
<ul className="space-y-2">
<li className="items-center flex gap-1">
1. Login to your server
</li>
<li>
2. When you are logged in run the following command
<div className="flex relative flex-col gap-4 w-full mt-2">
<CodeEditor
lineWrapping
language="properties"
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
readOnly
className="font-mono opacity-60"
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</li>
<li className="mt-1">
3. You're done, follow the next step to insert the
details of your server.
</li>
</ul>
</div>
)}
{selectedOption === "provider" && (
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Provider Setup Instructions
</span>
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key
<button
type="button"
className="right-2 top-8"
onClick={() => {
copy(
cloudSSHKey?.publicKey || "Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4 text-muted-foreground" />
</button>
</div>
</div>
</div>
<p className="text-sm mt-2">
Use this public key when creating a server in your
preferred provider (Hostinger, Digital Ocean, Hetzner,
etc.)
</p>
<Link
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2 mt-2"
>
View Tutorial <ExternalLinkIcon className="size-4" />
</Link>
</div>
)}
</div>
</>
)}
</div>
</CardContent>
</Card>
);
};
@@ -1,136 +0,0 @@
import { useState } from "react";
import {
type LogLine,
parseLogs,
} from "@/components/dashboard/docker/logs/utils";
import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { EditScript } from "../edit-script";
export const Setup = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
api.server.setupWithLogs.useSubscription(
{
serverId: serverId,
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Deployment completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
},
);
return (
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Select the server and click on setup server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Setup Server</CardTitle>
<CardDescription>
To setup a server, please click on the button below.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
<span className="text-sm text-muted-foreground text-center">
When your server is ready, you can click on the button below, to
directly run the script we use for setup the server or directly
modify the script
</span>
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
type="default"
description="This will setup the server and all associated data"
onClick={async () => {
setIsDeploying(true);
}}
>
<Button>Setup Server</Button>
</DialogAction>
</div>
</div>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>
</div>
);
};
@@ -1,179 +0,0 @@
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { StatusRow } from "../gpu-support";
export const Verify = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data, refetch, error, isPending, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const [isRefreshing, setIsRefreshing] = useState(false);
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Select a server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<PcCase className="size-5" />
<CardTitle className="text-xl">Setup Validation</CardTitle>
</div>
<CardDescription>
Check if your server is ready for deployment
</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
{isPending ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
</p>
<div className="grid gap-2.5">
<StatusRow
label="Docker Installed"
isEnabled={data?.docker?.enabled}
description={
data?.docker?.enabled
? `Installed: ${data?.docker?.version}`
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
description={
data?.nixpacks?.enabled
? `Installed: ${data?.nixpacks?.version}`
: undefined
}
/>
<StatusRow
label="Buildpacks Installed"
isEnabled={data?.buildpacks?.enabled}
description={
data?.buildpacks?.enabled
? `Installed: ${data?.buildpacks?.version}`
: undefined
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
description={
data?.isMainDirectoryInstalled
? "Created"
: "Not Created"
}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};
@@ -1,441 +1,261 @@
import { defineStepper } from "@stepperize/react";
import {
BookIcon,
Code2,
Database,
GitMerge,
Globe,
Plug,
Puzzle,
Users,
CheckIcon,
DatabaseIcon,
GitMergeIcon,
GlobeIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DeployStep } from "@/components/dashboard/onboarding/steps/deploy-step";
import { ProjectStep } from "@/components/dashboard/onboarding/steps/project-step";
import { ServerStep } from "@/components/dashboard/onboarding/steps/server-step";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { CreateServer } from "./create-server";
import { CreateSSHKey } from "./create-ssh-key";
import { Setup } from "./setup";
import { Verify } from "./verify";
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
export const { useStepper, steps, Scoped } = defineStepper(
{
id: "requisites",
title: "Requisites",
description: "Check your requisites",
},
{
id: "create-ssh-key",
title: "SSH Key",
description: "Create your ssh key",
},
{
id: "connect-server",
title: "Connect",
description: "Connect",
},
{ id: "setup", title: "Setup", description: "Setup your server" },
{ id: "verify", title: "Verify", description: "Verify your server" },
{ id: "complete", title: "Complete", description: "Checkout complete" },
const { useStepper, steps, Scoped } = defineStepper(
{ id: "welcome", title: "Welcome" },
{ id: "project", title: "New project" },
{ id: "server", title: "Connect server" },
{ id: "deploy", title: "Ship something" },
{ id: "complete", title: "You're live" },
);
export const WelcomeSubscription = () => {
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.",
},
];
const WelcomeIntro = ({ onNext }: { onNext: () => void }) => (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Welcome
</span>
<h1 className="text-xl font-semibold tracking-tight">
Welcome to Dokploy Cloud
</h1>
<p className="text-muted-foreground leading-relaxed">
Thanks for subscribing — you're all set up. Next, connect a server so
you can start deploying.
</p>
</div>
<Button size="lg" onClick={onNext} className="w-fit px-8">
Get started
</Button>
</div>
);
const CompleteIntro = ({ onFinish }: { onFinish: () => void }) => {
const [showConfetti, setShowConfetti] = useState(false);
useEffect(() => {
setShowConfetti(true);
}, []);
return (
<div className="flex flex-col gap-8">
<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-3">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
Done
</span>
<h1 className="text-xl font-semibold tracking-tight">
You're all set.
</h1>
<p className="text-muted-foreground leading-relaxed">
Your server is connected — here's some of what you can do next.
</p>
</div>
<dl className="flex flex-col divide-y">
{features.map((feature, index) => (
<div key={feature.title} className="flex gap-6 py-4 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" onClick={onFinish}>
Go to dashboard
</Button>
<Link
href="https://discord.com/invite/2tBnJ3jDJc"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Need help? Join our Discord →
</Link>
</div>
</div>
);
};
export const WelcomeSubscription = () => {
const router = useRouter();
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const router = useRouter();
const { push } = router;
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
if (!confettiShown) {
setShowConfetti(true);
localStorage.setItem("hasShownConfetti", "true");
}
}, [showConfetti]);
// This flow can also fire for an existing customer upgrading their plan
// (any checkout success redirects here), not just a first-time
// subscriber — so "New project" only belongs in the flow when they don't
// already have one. Mirrors the isCloud-based step filtering in the main
// onboarding wizard.
const { data: projects } = api.project.all.useQuery();
const hasExistingProject = (projects?.length ?? 0) > 0;
const visibleStepIds = hasExistingProject
? steps.filter((step) => step.id !== "project").map((step) => step.id)
: steps.map((step) => step.id);
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
const isLast = visibleIndex === visibleStepIds.length - 1;
const goToNextVisible = () => {
const nextId = visibleStepIds[visibleIndex + 1];
if (nextId) stepper.goTo(nextId);
};
const [projectId, setProjectId] = useState<string | undefined>();
const [environmentId, setEnvironmentId] = useState<string | undefined>();
const firstExistingProjectId = projects?.[0]?.projectId;
const { data: existingEnvironments } = api.environment.byProjectId.useQuery(
{ projectId: firstExistingProjectId ?? "" },
{ enabled: !!firstExistingProjectId && !environmentId },
);
const resolvedEnvironmentId =
environmentId ?? existingEnvironments?.[0]?.environmentId;
const close = (destination: string) => {
setIsOpen(false);
router.push(destination);
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
const { success, ...rest } = router.query;
router.replace(
{ pathname: router.pathname, query: rest },
undefined,
{
shallow: true,
},
);
}
if (!open) close("/dashboard/settings/servers");
}}
>
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">
{showConfetti && (
<ConfettiExplosion
duration={3000}
force={0.3}
particleSize={12}
particleCount={300}
className="z-9999"
zIndex={9999}
width={1500}
/>
)}
</div>
<DialogHeader>
<DialogTitle className="text-2xl text-center">
Welcome To Dokploy Cloud 🎉
</DialogTitle>
<DialogDescription className="text-center max-w-xl mx-auto">
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
onboard. Before you dive in, you'll need to configure your remote
server to unlock all the features we offer.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex justify-between">
<h2 className="text-lg font-semibold">Steps</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Step {stepper.current.index + 1} of {steps.length}
</span>
<div />
</div>
</div>
<Scoped>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex items-center gap-4 shrink-0">
<Button
type="button"
role="tab"
variant={
index <= stepper.current.index ? "secondary" : "ghost"
}
aria-current={
stepper.current.id === step.id ? "step" : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
onClick={() => stepper.goTo(step.id)}
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < stepper.current.index
? "bg-primary"
: "bg-muted"
}`}
/>
<DialogContent className="sm:max-w-2xl">
<Scoped>
<nav aria-label="Steps" className="flex items-center gap-2 pt-2">
{visibleStepIds.map((id, index) => {
const isDone = index < visibleIndex;
const isCurrent = stepper.current.id === id;
return (
<Fragment key={id}>
<button
type="button"
disabled={!isDone}
onClick={() => stepper.goTo(id)}
aria-current={isCurrent ? "step" : undefined}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-[11px] transition-colors",
isCurrent
? "border-2 border-primary text-primary font-semibold"
: isDone
? "bg-primary text-primary-foreground cursor-pointer"
: "border border-border text-muted-foreground",
)}
</React.Fragment>
))}
</ol>
</nav>
{stepper.switch({
requisites: () => (
<div className="flex flex-col gap-2 border p-4 rounded-lg">
<span className="text-primary text-base font-bold">
Before getting started, please follow the steps below to
ensure the best experience:
</span>
<div>
<p className="text-primary text-sm font-medium">
Supported Distributions:
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>Ubuntu 24.04 LTS</li>
<li>Ubuntu 23.10</li>
<li>Ubuntu 22.04 LTS</li>
<li>Ubuntu 20.04 LTS</li>
<li>Ubuntu 18.04 LTS</li>
<li>Debian 12</li>
<li>Debian 11</li>
<li>Debian 10</li>
<li>Fedora 40</li>
<li>CentOS 9</li>
<li>CentOS 8</li>
</ul>
</div>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server
(VPS) to proceed, we recommend to use one of these
providers since has been heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get €20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to
use one of the above, to avoid issues.
</AlertBlock>
</div>
</div>
),
"create-ssh-key": () => <CreateSSHKey />,
"connect-server": () => <CreateServer stepper={stepper} />,
setup: () => <Setup />,
verify: () => <Verify />,
complete: () => {
const features = [
{
title: "Scalable Deployments",
description:
"Deploy and scale your applications effortlessly to handle any workload.",
icon: <Database className="text-primary" />,
},
{
title: "Automated Backups",
description: "Protect your data with automatic backups",
icon: <Database className="text-primary" />,
},
{
title: "Open Source Templates",
description:
"Big list of common open source templates in one-click",
icon: <Puzzle className="text-primary" />,
},
{
title: "Custom Domains",
description:
"Link your own domains to your applications for a professional presence.",
icon: <Globe className="text-primary" />,
},
{
title: "CI/CD Integration",
description:
"Implement continuous integration and deployment workflows to streamline development.",
icon: <GitMerge className="text-primary" />,
},
{
title: "Database Management",
description:
"Efficiently manage your databases with intuitive tools.",
icon: <Database className="text-primary" />,
},
{
title: "Team Collaboration",
description:
"Collaborate with your team on shared projects with customizable permissions.",
icon: <Users className="text-primary" />,
},
{
title: "Multi-language Support",
description:
"Deploy applications in multiple programming languages to suit your needs.",
icon: <Code2 className="text-primary" />,
},
{
title: "API Access",
description:
"Integrate and manage your applications via robust and well-documented APIs.",
icon: <Plug className="text-primary" />,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">You're All Set!</h2>
<p className="text-muted-foreground">
Did you know you can deploy any number of applications
that your server can handle?
</p>
<p className="text-muted-foreground">
Here are some of the things you can do with Dokploy
Cloud:
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{features.map((feature) => (
<div
key={feature.title}
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
>
<div className="text-3xl mb-2">{feature.icon}</div>
<h3 className="text-lg font-medium mb-1">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground">
{feature.description}
</p>
</div>
))}
</div>
<div className="flex flex-col gap-2 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<div className="flex flex-row gap-4">
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
<Button className="rounded-full w-fit">
<Link
href="https://github.com/Dokploy/dokploy"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<GithubIcon />
Github
</Link>
</Button>
<Button
className="rounded-full w-fit"
variant="outline"
>
<Link
href="https://docs.dokploy.com/docs/core"
aria-label="Dokploy Docs"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<BookIcon size={16} />
Docs
</Link>
</Button>
</div>
</div>
</div>
);
},
>
{isDone ? <CheckIcon className="size-3.5" /> : index + 1}
</button>
{index < visibleStepIds.length - 1 && (
<span
className={cn(
"h-px flex-1",
isDone ? "bg-primary" : "bg-border",
)}
/>
)}
</Fragment>
);
})}
</Scoped>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
{!stepper.isLast && (
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
push("/dashboard/settings/servers");
}}
>
Skip for now
</Button>
)}
</nav>
</Scoped>
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>
Back
</Button>
<Button
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/home");
} else {
stepper.next();
}
<div className="py-2">
{stepper.switch({
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
project: () => (
<ProjectStep
plainTitle
onNext={(project) => {
setProjectId(project.projectId);
setEnvironmentId(project.environmentId);
goToNextVisible();
}}
>
{stepper.isLast ? "Complete" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
/>
),
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
deploy: () => (
<DeployStep
plainTitle
environmentId={resolvedEnvironmentId}
onNext={goToNextVisible}
/>
),
complete: () => (
<CompleteIntro onFinish={() => close("/dashboard/home")} />
),
})}
</div>
{!isLast && (
<DialogFooter>
<Button
variant="ghost"
onClick={() => close("/dashboard/settings/servers")}
>
Skip for now
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -40,6 +40,7 @@ const providerLabels = {
hashicorp: "HashiCorp Vault / OpenBao",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -61,6 +62,7 @@ const VaultProviderSchema = z
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -79,6 +81,7 @@ const VaultProviderSchema = z
region: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
parameterPath: z.string(),
serviceToken: z.string(),
awsEndpoint: z.string(),
vaultUri: z.string(),
@@ -136,7 +139,8 @@ const VaultProviderSchema = z
});
}
if (
data.providerType === "aws" &&
(data.providerType === "aws" ||
data.providerType === "aws-parameter-store") &&
data.awsEndpoint &&
!isValidUrl(data.awsEndpoint)
) {
@@ -146,6 +150,17 @@ const VaultProviderSchema = z
path: ["awsEndpoint"],
});
}
if (
data.providerType === "aws-parameter-store" &&
data.parameterPath &&
!data.parameterPath.trim().startsWith("/")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Parameter discovery path must start with /",
path: ["parameterPath"],
});
}
if (
data.providerType === "scaleway" &&
data.scalewayApiUrl &&
@@ -200,6 +215,11 @@ const VaultProviderSchema = z
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
"aws-parameter-store": [
["region", "Region is required"],
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
doppler: [["serviceToken", "Service Token is required"]],
azure: [
["vaultUri", "Vault URI is required"],
@@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
region: "",
accessKeyId: "",
secretAccessKey: "",
parameterPath: "",
serviceToken: "",
awsEndpoint: "",
vaultUri: "",
@@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
};
case "aws-parameter-store":
return {
providerType: "aws-parameter-store" as const,
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
parameterPath: data.parameterPath.trim() || undefined,
};
case "doppler":
return {
providerType: "doppler" as const,
@@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
environmentSlug: provider.config.environmentSlug,
secretPath: provider.config.secretPath,
}),
...(provider.config.providerType === "aws" && {
...((provider.config.providerType === "aws" ||
provider.config.providerType === "aws-parameter-store") && {
region: provider.config.region,
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
awsEndpoint: provider.config.endpoint ?? "",
}),
...(provider.config.providerType === "aws-parameter-store" && {
parameterPath: provider.config.parameterPath ?? "",
}),
...(provider.config.providerType === "doppler" && {
serviceToken: provider.config.serviceToken,
dopplerProject: provider.config.project ?? "",
@@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "aws" && (
{(providerType === "aws" ||
providerType === "aws-parameter-store") && (
<>
<FormField
control={form.control}
@@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
{providerType === "aws-parameter-store" && (
<FormField
control={form.control}
name="parameterPath"
render={({ field }) => (
<FormItem>
<FormLabel>Discovery path (optional)</FormLabel>
<FormControl>
<Input placeholder="/production/my-app" {...field} />
</FormControl>
<FormDescription>
Limits parameter browsing and autocomplete to this
hierarchy. IAM permissions remain the security
boundary.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "aws" ? (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
JSON secrets
</FormDescription>
) : (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
SecureString parameters are decrypted automatically.
Resolution requires <code>ssm:GetParameters</code>.
Discovery requires <code>ssm:DescribeParameters</code>.
Customer-managed KMS keys also require{" "}
<code>kms:Decrypt</code>.
</FormDescription>
)}
</>
)}
@@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -32,7 +33,7 @@ export const ShowVaultProviders = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<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 w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -7,6 +7,7 @@ import {
Server,
Sparkles,
Stars,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -14,6 +15,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
@@ -126,13 +128,13 @@ export const UpdateServer = ({
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="max-w-lg">
<div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold">
<DialogContent className="max-w-lg" showCloseButton={false}>
<div className="flex items-center gap-2 mb-8">
<DialogTitle className="text-2xl font-semibold mr-auto">
Web Server Update
</DialogTitle>
{dokployVersion && (
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion}{" "}
@@ -141,6 +143,12 @@ export const UpdateServer = ({
</span>
</div>
)}
<DialogClose asChild>
<Button variant="ghost" size="icon-sm" className="shrink-0">
<X />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
</div>
{/* Initial state */}
@@ -0,0 +1,245 @@
import type { ServiceType } from "@dokploy/server/db/schema";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ArrowRightLeft } from "lucide-react";
import { 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 { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../docker/logs/utils";
const LOCAL_SERVER = "dokploy";
const transferSchema = z.object({
targetServerId: z.string().min(1, { message: "Select a target server" }),
removeSourceData: z.boolean(),
});
type TransferForm = z.infer<typeof transferSchema>;
interface Props {
id: string;
type: ServiceType;
serverId?: string | null;
}
export const TransferService = ({ id, type, serverId }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [logs, setLogs] = useState<LogLine[]>([]);
const [request, setRequest] = useState<{
targetServerId: string | null;
removeSourceData: boolean;
} | null>(null);
const targets = [
...(!isCloud && serverId
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
: []),
...(servers ?? []).filter((server) => server.serverId !== serverId),
];
const form = useForm<TransferForm>({
defaultValues: { targetServerId: "", removeSourceData: false },
resolver: zodResolver(transferSchema),
});
api.transfer.start.useSubscription(
{
serviceType: type,
serviceId: id,
targetServerId: request?.targetServerId ?? null,
removeSourceData: request?.removeSourceData ?? false,
},
{
enabled: isTransferring && request !== null,
onData(line) {
setLogs((prev) => [...prev, ...parseLogs(line)]);
if (line.startsWith("Transfer completed")) {
setIsTransferring(false);
toast.success("Service transferred successfully");
utils.invalidate();
} else if (line.startsWith("Transfer failed")) {
setIsTransferring(false);
toast.error("Transfer failed, check the logs");
utils.invalidate();
}
},
onError(error) {
setIsTransferring(false);
toast.error(error.message);
},
},
);
const onSubmit = (values: TransferForm) => {
setRequest({
targetServerId:
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
removeSourceData: values.removeSourceData,
});
setLogs([]);
setIsDrawerOpen(true);
setIsTransferring(true);
setIsOpen(false);
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
isLoading={isTransferring}
>
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Transfer to another server</DialogTitle>
<DialogDescription>
Moves this service with its volumes, bind mounts, file mounts and
configuration to the selected server, then deploys it there.
</DialogDescription>
</DialogHeader>
{targets.length === 0 ? (
<AlertBlock type="info">
There are no other servers available to transfer this service to.
</AlertBlock>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-transfer-service"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="targetServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Target server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{targets.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="removeSourceData"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Remove volumes from the source server after the
transfer
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<AlertBlock type="warning">
<ul className="list-disc pl-4 space-y-1">
<li>The service is stopped while its data is copied.</li>
<li>
Point your DNS records to the new server, certificates are
issued again there.
</li>
<li>
Networks that only exist on the current server are
detached.
</li>
<li>
Bind mount host paths are copied but never deleted from
the source server.
</li>
</ul>
</AlertBlock>
</form>
</Form>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{targets.length > 0 && (
<Button
isLoading={isTransferring}
form="hook-form-transfer-service"
type="submit"
>
Transfer
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
filteredLogs={logs}
/>
</>
);
};
@@ -91,8 +91,32 @@ export const PorkbunIcon = ({ className }: Props) => (
</svg>
);
export const InfomaniakIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
</svg>
);
export const OvhIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
infomaniak: InfomaniakIcon,
ovh: OvhIcon,
} as const;
@@ -608,6 +608,7 @@ export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
"aws-parameter-store": AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
@@ -1,4 +1,6 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
@@ -11,6 +13,7 @@ interface Props {
}
export const DashboardLayout = ({ children, metaName }: Props) => {
const router = useRouter();
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { config: whitelabeling } = useWhitelabeling();
@@ -24,6 +27,21 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
const isChatEnabled = isCloud === true && currentPlan === "startup";
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
const shouldRedirectToOnboarding =
router.pathname !== "/dashboard/home" &&
onboardingStatus?.shouldShowOnboarding === true;
useEffect(() => {
if (shouldRedirectToOnboarding) {
router.replace("/dashboard/home");
}
}, [shouldRedirectToOnboarding, router]);
if (shouldRedirectToOnboarding) {
return null;
}
return (
<>
{metaName && (
+18 -11
View File
@@ -43,6 +43,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
@@ -100,6 +101,7 @@ import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { TrialBanner } from "../dashboard/billing/trial-banner";
import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";
import { Logo } from "../shared/logo";
@@ -616,7 +618,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<SidebarMenuItem className={"w-full min-w-0"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@@ -632,14 +634,13 @@ function SidebarLogo() {
>
<div
className={cn(
"flex items-center gap-2",
"flex min-w-0 flex-1 items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
)}
>
<Logo
@@ -652,22 +653,27 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start",
"flex flex-col items-start min-w-0 flex-1",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
<div className="flex items-center gap-1.5 min-w-0 w-full">
<TruncateTooltip
text={
activeOrganization?.name ?? "Select Organization"
}
className="text-sm font-medium"
/>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")}
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>
@@ -1224,6 +1230,7 @@ export default function Page({ children }: Props) {
<SidebarRail />
</Sidebar>
<SidebarInset>
{isCloud === true && <TrialBanner />}
{!includesProjects && (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between w-full px-4">
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -83,6 +83,11 @@ const azureMapping: ClaimMapping = {
image: "",
};
// id: "sub",
// email: "preferred_username",
// emailVerified: "email_verified",
// name: "name",
const genericMapping: ClaimMapping = {
id: "sub",
email: "email",
@@ -228,10 +233,9 @@ export function RegisterOidcDialog({
mapping: {
id: oidc?.mapping?.id ?? baseMapping.id,
email: oidc?.mapping?.email ?? baseMapping.email,
emailVerified:
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
emailVerified: oidc?.mapping?.emailVerified ?? "",
name: oidc?.mapping?.name ?? baseMapping.name,
image: oidc?.mapping?.image ?? baseMapping.image,
image: oidc?.mapping?.image ?? "",
},
});
}, [data, open, form]);
@@ -1,39 +0,0 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}
@@ -47,8 +47,8 @@ const formSchema = z.object({
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
metaTitle: z.string(),
footerText: z.string(),
ogImageUrl: safeUrlField,
});
type FormSchema = z.infer<typeof formSchema>;
@@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
docsUrl: "",
errorPageTitle: "",
errorPageDescription: "",
metaTitle: "",
footerText: "",
ogImageUrl: "",
},
resolver: zodResolver(formSchema),
});
@@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
docsUrl: data.docsUrl ?? "",
errorPageTitle: data.errorPageTitle ?? "",
errorPageDescription: data.errorPageDescription ?? "",
metaTitle: data.metaTitle ?? "",
footerText: data.footerText ?? "",
ogImageUrl: data.ogImageUrl ?? "",
});
}
}, [data, form]);
@@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
docsUrl: values.docsUrl || null,
errorPageTitle: values.errorPageTitle || null,
errorPageDescription: values.errorPageDescription || null,
metaTitle: values.metaTitle || null,
footerText: values.footerText || null,
ogImageUrl: values.ogImageUrl || null,
},
})
.then(async () => {
@@ -388,6 +388,27 @@ export function WhitelabelingSettings() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="ogImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>OG Image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/og.png"
{...field}
/>
</FormControl>
<FormDescription>
Open Graph image used for link previews on social media
and messaging platforms. Recommended size: 1200x630px.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@@ -441,32 +462,15 @@ export function WhitelabelingSettings() {
</CardContent>
</Card>
{/* Metadata & Links Section */}
{/* Links Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Metadata & Links</CardTitle>
<CardTitle>Links</CardTitle>
<CardDescription>
Customize the page title, footer text, and sidebar links.
Customize the footer text and sidebar links.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="metaTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Page Title</FormLabel>
<FormControl>
<Input placeholder="Dokploy" {...field} />
</FormControl>
<FormDescription>
Browser tab title. Defaults to "Dokploy" if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="footerText"
@@ -0,0 +1,78 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
Tooltip,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface Props extends React.HTMLAttributes<HTMLParagraphElement> {
text: string;
}
export const TruncateTooltip = ({ text, className, ...props }: Props) => {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const element = textRef.current;
if (!element) return;
const checkTruncation = () => {
const truncated = element.scrollWidth > element.clientWidth;
setIsTruncated(truncated);
if (!truncated) {
setIsOpen(false);
}
};
checkTruncation();
const resizeObserver = new ResizeObserver(() => {
checkTruncation();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [text]);
const content = (
<p ref={textRef} className={cn("truncate", className)} {...props}>
{text}
</p>
);
return (
<TooltipProvider>
<Tooltip
delayDuration={0}
open={isOpen}
onOpenChange={(open) => {
// Only allow opening if it's actually truncated
if (isTruncated) {
setIsOpen(open);
} else {
setIsOpen(false);
}
}}
>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side="bottom"
align="start"
className="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md max-w-[280px] break-words animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<p>{text}</p>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
);
};
+16 -2
View File
@@ -10,13 +10,24 @@ interface DropzoneProps
"value" | "onChange"
> {
classNameWrapper?: string;
classNameContent?: string;
className?: string;
dropMessage: string;
onChange: (acceptedFiles: FileList | null) => void;
}
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
(
{
className,
classNameWrapper,
classNameContent,
dropMessage,
onChange,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement | null>(null);
// Function to handle drag over event
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
)}
>
<CardContent
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
className={cn(
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
classNameContent,
)}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleButtonClick}
@@ -0,0 +1,7 @@
ALTER TABLE "user" ADD COLUMN "onboardingCompletedAt" timestamp;--> statement-breakpoint
-- Backfill only: existing users shouldn't see the onboarding wizard just
-- because this column is new. Not a column-level DEFAULT — that would also
-- apply to rows inserted after this migration, and newly created users
-- (self-hosted setup, cloud signups) need onboardingCompletedAt to stay NULL
-- so the wizard still shows for them.
UPDATE "user" SET "onboardingCompletedAt" = now() WHERE "onboardingCompletedAt" IS NULL;
@@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
@@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD COLUMN "domain_verified" boolean DEFAULT true NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null}'::jsonb;
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,28 @@
CREATE TYPE "public"."sandboxNetworkMode" AS ENUM('isolated', 'internet');--> statement-breakpoint
CREATE TYPE "public"."sandboxStatus" AS ENUM('creating', 'running', 'killed', 'error');--> statement-breakpoint
CREATE TABLE "sandbox" (
"sandboxId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"environmentId" text NOT NULL,
"serverId" text,
"image" text NOT NULL,
"template" text,
"containerId" text,
"status" "sandboxStatus" DEFAULT 'creating' NOT NULL,
"cpu" real DEFAULT 1 NOT NULL,
"memoryMb" integer DEFAULT 512 NOT NULL,
"pidsLimit" integer DEFAULT 256 NOT NULL,
"timeoutMs" integer DEFAULT 300000 NOT NULL,
"expiresAt" timestamp with time zone,
"lastActivityAt" timestamp with time zone,
"networkMode" "sandboxNetworkMode" DEFAULT 'isolated' NOT NULL,
"envVars" text,
"workdir" text DEFAULT '/home/user' NOT NULL,
"user" text,
"runtime" text,
"createdAt" text NOT NULL,
"killedAt" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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