Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu 18fb2547e4 fix: block bind mounts to sensitive host paths for non-owner/admin members
The Mounts feature (application/compose/database services) let any
member with volume:create access set an arbitrary bind mount hostPath,
with zero validation. Since generateBindMounts wires that path straight
into the Swarm service spec, a non-privileged member could mount
/var/run/docker.sock, /etc/dokploy, or other host-critical paths and
escalate to root on the Dokploy control host - same impact as bringing
your own docker-compose.yml with a docker.sock mount, but reachable
through the plain Mounts UI on any service type.

Add isDangerousBindMountPath() with a fixed blocklist of host-critical
paths (docker.sock, /etc/dokploy, /root, /boot, /proc, /sys, docker's
data dir) and isPrivilegedOrgRole(); mount.create/update now reject a
bind mount targeting one of those paths unless the caller is the org's
owner or admin. Single-user self-hosted installs are unaffected - the
sole user is always the owner.
2026-09-01 03:49:12 -06:00
216 changed files with 6752 additions and 76811 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__*` resolves its URL from
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from package.json
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "$LATEST_TAG"
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
run: |
if [ "$VERSION" != "$LATEST_TAG" ]; then
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary
gh pr create \
--title "🚀 Release $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to $VERSION
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
+19 -28
View File
@@ -5,38 +5,33 @@ on:
branches: [main, canary]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push-cloud-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.cloud
@@ -53,28 +48,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.schedule
@@ -87,28 +80,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.server
+42 -60
View File
@@ -8,23 +8,18 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -32,15 +27,15 @@ jobs:
- name: Set tag and version
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -48,7 +43,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
@@ -58,15 +53,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -75,15 +68,15 @@ jobs:
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -91,7 +84,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
@@ -103,22 +96,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
@@ -130,7 +121,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
@@ -147,37 +138,32 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
@@ -193,19 +179,15 @@ jobs:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
@@ -220,63 +202,63 @@ jobs:
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version $NPM_VERSION"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version $NPM_VERSION"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version $NPM_VERSION"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+2 -7
View File
@@ -6,20 +6,15 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v5
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
+2 -4
View File
@@ -16,19 +16,17 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="$MERGE_SHA"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
+1 -5
View File
@@ -7,16 +7,12 @@ concurrency:
group: hotfix-to-main
cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+19 -28
View File
@@ -7,23 +7,18 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -31,17 +26,17 @@ jobs:
- name: Set tag
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -52,15 +47,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,17 +61,17 @@ jobs:
- name: Set
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -91,29 +84,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
+3 -5
View File
@@ -14,11 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"
+4 -7
View File
@@ -12,20 +12,17 @@ on:
workflow_dispatch:
permissions:
contents: read
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
@@ -28,9 +28,6 @@ env:
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
@@ -424,10 +421,8 @@ jobs:
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
STATUS="$JOB_STATUS"
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
+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 --filter=dokploy run reset-password
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm --filter=dokploy run reset-password user@example.com
pnpm run reset-password -- user@example.com
```
Both commands print the new randomly generated password to the console.
-10
View File
@@ -6,13 +6,3 @@ 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,7 +4,6 @@
"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"
@@ -1,156 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -1,6 +1,4 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
@@ -50,48 +48,3 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -1,14 +0,0 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -1,44 +0,0 @@
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,16 +54,7 @@ const inputEncoding: Record<string, string> = {
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
const hasDocker = () => {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
describe("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
@@ -1,113 +0,0 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
addAppNameToPreventCollision,
addSuffixToAllProperties,
} from "@dokploy/server";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { expect, test, vi } from "vitest";
import { stringify } from "yaml";
import composeSpec from "../../../components/shared/compose-spec.json";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
const modelCompose = {
services: {
app: {
image: "my-chat-app",
models: ["llm"],
},
worker: {
image: "my-worker",
models: {
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
},
},
},
models: {
llm: {
model: "ai/smollm2",
context_size: 2048,
runtime_flags: ["--verbose"],
},
},
} satisfies ComposeSpecification;
test("bundled compose schema declares models", () => {
expect(composeSpec.properties).toHaveProperty("models");
expect(composeSpec.definitions.model.required).toEqual(["model"]);
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
});
test("suffixing does not rename model identifiers or drop model config", () => {
const updated = addSuffixToAllProperties(
structuredClone(modelCompose),
"testhash",
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.models?.llm?.context_size).toBe(2048);
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
expect(updated.services?.["worker-testhash"]?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
expect(updated.services).not.toHaveProperty("app");
expect(updated.models).not.toHaveProperty("llm-testhash");
});
test("isolated deployment preserves model identifiers", () => {
const updated = addAppNameToPreventCollision(
structuredClone(modelCompose),
"chat-app",
false,
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.services?.app?.models).toEqual(["llm"]);
expect(updated.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
test("raw remote compose conversion preserves models", async () => {
const converted = await addDomainToCompose(
{
appName: "chat-app",
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
composePath: "./docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Compose,
[],
);
expect(execAsyncRemote).not.toHaveBeenCalled();
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
expect(converted?.services?.app?.models).toEqual(["llm"]);
expect(converted?.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
@@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
+2 -23
View File
@@ -323,11 +323,9 @@ describe("cloudflareClient.upsertRecord", () => {
expect(createInit.method).toBe("POST");
});
it("updates the existing record when content matches", async () => {
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
)
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
@@ -346,25 +344,6 @@ 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([]))
@@ -1,486 +0,0 @@
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
@@ -1,581 +0,0 @@
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",
);
});
});
+2 -28
View File
@@ -121,11 +121,9 @@ describe("porkbunClient.upsertRecord", () => {
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record when content matches", async () => {
it("edits the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
)
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
@@ -140,30 +138,6 @@ 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: [] }))
@@ -1,149 +0,0 @@
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
@@ -1,195 +0,0 @@
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");
});
});
-218
View File
@@ -20,7 +20,6 @@ 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";
@@ -432,223 +431,6 @@ 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("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets the later import win when two define the same key", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/base",
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
},
{
secretPath: "/override",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://override" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
// Infisical documents this order: "If two imports carry a secret with the
// same name, the value from the bottom-most import wins." The response
// lists imports in that order, so assigning them in sequence matches it.
expect(result).toEqual({ DB_URL: "postgres://override" });
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
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));
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});
@@ -1,66 +0,0 @@
import { apiCreateLibsql } from "@dokploy/server/db/schema";
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
import { describe, expect, it } from "vitest";
describe("apiCreateLibsql appName", () => {
const validBase = {
name: "My libSQL",
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
environmentId: "env-1",
description: null,
databaseUser: "admin",
databasePassword: "secretPassword1",
sqldNode: "primary" as const,
sqldPrimaryUrl: null,
enableNamespaces: false,
serverId: null,
};
it.each([
["a space", "my libsql"],
["an at sign", "repro@lib"],
["a slash", "team/libsql"],
["a colon", "libsql:1"],
])("refuses an appName with %s", (_, appName) => {
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.message)).toEqual([
APP_NAME_MESSAGE,
]);
}
});
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "my-libsql.db_1",
});
expect(result.success).toBe(true);
});
it("refuses an appName longer than 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(64),
});
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
}
});
it("accepts an appName of 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(63),
});
expect(result.success).toBe(true);
});
});
@@ -1,104 +0,0 @@
import {
getHostSystemStats,
isPhysicalDisk,
} from "@dokploy/server/monitoring/utils";
import { describe, expect, it, vi } from "vitest";
const GiB = 1024 ** 3;
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
device,
readBytes: { toBytes: () => readGiB * GiB },
writeBytes: { toBytes: () => writeGiB * GiB },
});
vi.mock("node-os-utils", () => ({
OSUtils: class {
cpu = { usage: async () => ({ success: false }) };
memory = { info: async () => ({ success: false }) };
network = { overview: async () => ({ success: false }) };
disk = {
stats: async () => ({
success: true,
data: [
diskStat("nvme0n1", 50, 100),
diskStat("nvme0n1p1", 50, 100),
diskStat("nvme1n1", 50, 100),
diskStat("nvme1n1p1", 50, 100),
diskStat("md0", 50, 100),
diskStat("md0p1", 50, 100),
diskStat("dm-0", 50, 100),
diskStat("zram0", 10, 20),
],
}),
};
},
}));
describe("isPhysicalDisk (#5513, #5385)", () => {
it.each([
"sda",
"sdab",
"hda",
"vda",
"xvda",
"nvme0n1",
"nvme1n1",
"mmcblk0",
])("counts whole disk %s", (device) => {
expect(isPhysicalDisk(device)).toBe(true);
});
it.each([
"sda1",
"sdab12",
"hda1",
"vda1",
"xvda1",
"nvme0n1p1",
"nvme1n1p4",
"mmcblk0p1",
])("skips partition %s", (device) => {
expect(isPhysicalDisk(device)).toBe(false);
});
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
"skips stacked device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
"skips virtual device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it("counts each write once on an mdadm RAID1 host", () => {
const devices = [
"nvme0n1",
"nvme0n1p1",
"nvme0n1p2",
"nvme0n1p3",
"nvme0n1p4",
"nvme1n1",
"nvme1n1p1",
"nvme1n1p2",
"nvme1n1p3",
"nvme1n1p4",
"md0",
"md1",
"md2",
"md3",
];
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
});
});
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
it("sums only whole physical disks", async () => {
const stats = await getHostSystemStats();
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
});
});
@@ -154,16 +154,7 @@ const hasRealMonitoring = () => {
);
};
const hasDocker = () => {
try {
execSync("docker info", { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
ogImageUrl: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
@@ -1,182 +0,0 @@
import {
checkDeletionCode,
generateDeletionCode,
serializeDeletionCode,
} from "@dokploy/server/services/account-deletion";
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cancelStripeSubscriptions } from "@/server/utils/billing";
const makeStripe = (
subscriptions: Array<{ id: string; status: string }>,
listError?: Error,
) => {
const stripe = {
subscriptions: {
list: vi.fn(() =>
listError
? Promise.reject(listError)
: Promise.resolve({ data: subscriptions }),
),
cancel: vi.fn(() => Promise.resolve({})),
},
};
return stripe as unknown as Stripe & typeof stripe;
};
describe("cancelStripeSubscriptions", () => {
test("cancels every non-terminal subscription without invoicing", async () => {
const stripe = makeStripe([
{ id: "sub_active", status: "active" },
{ id: "sub_trial", status: "trialing" },
{ id: "sub_past_due", status: "past_due" },
{ id: "sub_canceled", status: "canceled" },
{ id: "sub_expired", status: "incomplete_expired" },
]);
const result = await cancelStripeSubscriptions("cus_1", stripe);
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
customer: "cus_1",
status: "all",
limit: 100,
});
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
invoice_now: false,
prorate: false,
});
expect(result).toEqual({
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
});
});
test("treats a missing customer as having nothing to cancel", async () => {
const missing = new Stripe.errors.StripeInvalidRequestError({
type: "invalid_request_error",
code: "resource_missing",
message: "No such customer",
});
const stripe = makeStripe([], missing);
const result = await cancelStripeSubscriptions("cus_missing", stripe);
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(result).toEqual({ cancelledSubscriptions: [] });
});
test("propagates unexpected Stripe errors", async () => {
const failure = new Stripe.errors.StripeAPIError({
type: "api_error",
message: "boom",
});
const stripe = makeStripe([], failure);
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
failure,
);
});
});
describe("deleteHubSpotContactByEmail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("is skipped when no access token is configured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
"skipped",
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("calls the GDPR delete endpoint with the email as id", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(null, { status: 204 })),
);
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"deleted",
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token",
);
expect(JSON.parse(init.body as string)).toEqual({
idProperty: "email",
objectId: "a@b.com",
});
});
test("maps 404 to not_found and other errors to failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"not_found",
);
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"failed",
);
});
});
describe("account deletion codes", () => {
test("generates six-digit numeric codes", () => {
for (let i = 0; i < 50; i++) {
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
}
});
test("accepts the right code, ignoring surrounding whitespace", () => {
const value = serializeDeletionCode("482913");
expect(checkDeletionCode(value, "482913")).toEqual({
valid: true,
attempts: 0,
});
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
});
test("counts failed attempts without revealing the hash", () => {
const value = serializeDeletionCode("482913");
const first = checkDeletionCode(value, "000000");
expect(first.valid).toBe(false);
if (first.valid) throw new Error("unreachable");
expect(first.attempts).toBe(1);
expect(JSON.parse(first.nextValue)).toEqual({
...JSON.parse(value),
attempts: 1,
});
const second = checkDeletionCode(first.nextValue, "999999");
expect(second.valid).toBe(false);
if (second.valid) throw new Error("unreachable");
expect(second.attempts).toBe(2);
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
valid: true,
attempts: 2,
});
expect(value).not.toContain("482913");
});
});
@@ -1,60 +0,0 @@
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");
});
});
@@ -1,169 +0,0 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
signals: string[] = [];
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
// even if the process ignores it and keeps running.
ignoreSigterm = false;
kill(signal: NodeJS.Signals = "SIGTERM") {
this.signals.push(signal);
this.killed = true;
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
this.signalCode = signal;
this.emit("close", null, signal);
return true;
}
}
const children = vi.hoisted(() => [] as FakeChild[]);
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: vi.fn(() => {
const child = new FakeChild();
child.ignoreSigterm = nextChild.ignoreSigterm;
children.push(child);
return child;
}),
}));
const sshClients = vi.hoisted(
() => [] as { connect: ReturnType<typeof vi.fn> }[],
);
vi.mock("ssh2", () => ({
Client: vi.fn(function (this: Record<string, unknown>) {
this.on = vi.fn(() => this);
this.connect = vi.fn(() => this);
this.end = vi.fn();
sshClients.push(this as never);
}),
}));
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockFindServerById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
validateRequest: mockValidateRequest,
findServerById: mockFindServerById,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
readValidDirectory: () => true,
}));
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
const AUTH = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 50));
let server: http.Server;
let url: string;
beforeEach(async () => {
children.length = 0;
sshClients.length = 0;
nextChild.ignoreSigterm = false;
vi.clearAllMocks();
server = http.createServer();
setupDeploymentLogsWebSocketServer(server);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
});
afterEach(async () => {
vi.useRealTimers();
await new Promise((r) => server.close(r));
});
const connect = async (query = "") => {
const ws = new WebSocket(`${url}${query}`);
await new Promise((r) => ws.once("open", r));
return ws;
};
const closeClient = async (ws: WebSocket) => {
ws.close();
await new Promise((r) => ws.once("close", r));
await flush();
};
describe("deployment logs websocket (#5518)", () => {
it("kills tail when the client disconnects", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
expect(children).toHaveLength(1);
await closeClient(ws);
expect(children[0]!.signals).toContain("SIGTERM");
});
it("does not spawn tail when the client disconnects during auth", async () => {
const auth = deferred<typeof AUTH>();
mockValidateRequest.mockReturnValue(auth.promise);
const ws = await connect();
await closeClient(ws);
auth.resolve(AUTH);
await flush();
const leaked = children.filter(
(c) => c.exitCode === null && c.signalCode === null,
);
expect(leaked).toHaveLength(0);
});
it("does not open an SSH session when the client disconnects while loading the server", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const serverLookup = deferred<unknown>();
mockFindServerById.mockReturnValue(serverLookup.promise);
const ws = await connect("&serverId=srv-1");
await closeClient(ws);
serverLookup.resolve({
organizationId: "org-1",
sshKeyId: "key-1",
sshKey: { privateKey: "key" },
ipAddress: "10.0.0.1",
port: 22,
username: "root",
});
await flush();
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
expect(connected).toHaveLength(0);
});
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
nextChild.ignoreSigterm = true;
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
await closeClient(ws);
await new Promise((r) => setTimeout(r, 1100));
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
});
});
@@ -8,3 +8,4 @@ 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";
@@ -0,0 +1,31 @@
/**
* 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;
};
@@ -31,45 +31,6 @@ import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.39.0",
"0.38.0",
"0.37.1",
"0.37.0",
"0.36.4",
"0.36.3",
"0.36.2",
"0.36.1",
"0.36.0",
"0.35.0",
"0.34.0",
"0.33.0",
"0.32.0",
"0.31.2",
"0.31.1",
"0.31.0",
"0.30.1",
"0.30.0",
"0.29.0",
"0.28.0",
"0.27.2",
"0.27.1",
"0.27.0",
"0.26.1",
"0.26.0",
"0.25.0",
"0.24.0",
"0.23.0",
"0.22.2",
"0.22.1",
"0.22.0",
"0.21.0",
"0.20.0",
"0.19.0",
"0.18.0",
"0.17.2",
"0.17.1",
"0.17.0",
"0.16.0",
"0.15.4",
"0.15.3",
"0.15.2",
@@ -84,22 +45,12 @@ export const RAILPACK_VERSIONS = [
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.2",
"0.7.1",
"0.7.0",
"0.6.1",
"0.6.0",
"0.5.1",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.3",
"0.2.2",
"0.2.1",
"0.2.0",
"0.1.2",
"0.1.1",
"0.1.0",
] as const;
export enum BuildType {
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainId,
},
{
enabled: isOpen && !!domainId,
enabled: !!domainId,
},
);
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
applicationId: id,
},
{
enabled: isOpen && !!id,
enabled: !!id,
},
)
: api.compose.one.useQuery(
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
},
{
enabled: isOpen && !!id,
enabled: !!id,
},
);
@@ -187,14 +187,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery(
{
serverId: application?.serverId || "",
},
{
enabled: isOpen,
},
);
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
const {
data: services,
@@ -209,7 +204,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
{
retry: false,
refetchOnWindowFocus: false,
enabled: isOpen && type === "compose" && !!id,
enabled: type === "compose" && !!id,
},
);
@@ -1,3 +1,4 @@
import DOMPurify from "dompurify";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -13,7 +14,6 @@ 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,6 +89,15 @@ 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];
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
};
return (
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Volume Backups</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
</div>
</>
)}
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="px-0">
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -1,47 +0,0 @@
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();
const { mutateAsync: createCustomerPortalSession, isPending } =
api.stripe.createCustomerPortalSession.useMutation();
const handleAddPaymentMethod = async () => {
const session = await createCustomerPortalSession().catch(() => null);
if (session?.url) {
window.open(session.url);
return;
}
router.push("/dashboard/settings/billing");
};
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
return null;
}
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
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 ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
: "Your free trial ends today. Add a payment method to keep your servers."}
</span>
<Button
size="sm"
variant="default"
className="h-7"
isLoading={isPending}
onClick={handleAddPaymentMethod}
>
Add payment method
</Button>
</div>
);
};
@@ -22,7 +22,6 @@ 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 {
@@ -31,7 +30,6 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data) {
if (data?.command) {
form.reset({
command: data.command || "",
pullImages: data.pullImages ?? false,
command: data?.command || "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (formData: AddCommand) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
composeId,
command: formData.command,
pullImages: formData.pullImages,
command: data?.command,
})
.then(async () => {
toast.success("Command Updated");
@@ -114,30 +109,6 @@ 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"
@@ -1,216 +0,0 @@
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>
);
};
@@ -158,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -1,4 +1,10 @@
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -95,9 +101,12 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Backups</CardTitle>
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,8 +1,10 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -25,6 +27,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
export function ShowDeploymentsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@@ -450,140 +446,177 @@ export function ShowDeploymentsTable({
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will appear
here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
</>
)}
</>
)}
</div>
</div>
);
}
@@ -1,143 +0,0 @@
import copy from "copy-to-clipboard";
import { Copy, Loader2, Trash2 } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const CONFIRMATION_WORD = "DELETE";
export const DeleteAccountByEmail = () => {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [confirmation, setConfirmation] = useState("");
const [record, setRecord] = useState<string | null>(null);
const { mutateAsync, isPending } =
api.user.deleteAccountByEmail.useMutation();
const canConfirm =
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
const reset = () => {
setEmail("");
setConfirmation("");
setRecord(null);
};
const handleDelete = async () => {
try {
const result = await mutateAsync({ email: email.trim() });
setRecord(JSON.stringify(result, null, 2));
toast.success(`Account ${result.email} deleted`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deleting account",
);
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) reset();
}}
>
<DialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<Trash2 className="h-4 w-4" />
Delete account
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Delete an account by email</DialogTitle>
<DialogDescription>
Cancels every Stripe subscription and deletes the user with all the
organizations they own. Invoices stay in Stripe for accounting. Use
it to fulfil data deletion requests.
</DialogDescription>
</DialogHeader>
{record ? (
<div className="flex flex-col gap-3">
<AlertBlock type="success">
Deletion completed. Keep this record as evidence of the request
being fulfilled.
</AlertBlock>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
{record}
</pre>
<Button
variant="outline"
size="sm"
className="gap-2 self-end"
onClick={() => {
copy(record);
toast.success("Deletion record copied to clipboard");
}}
>
<Copy className="h-4 w-4" />
Copy record
</Button>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-email">Account email</Label>
<Input
id="delete-account-email"
type="email"
autoComplete="off"
placeholder="user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-word">
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
confirm
</Label>
<Input
id="delete-account-word"
autoComplete="off"
placeholder={CONFIRMATION_WORD}
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter>
{record ? (
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
) : (
<Button
variant="destructive"
disabled={!canConfirm || isPending}
onClick={handleDelete}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Delete account
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { DeleteAccountByEmail } from "./delete-account-by-email";
type User = typeof authClient.$Infer.Session.user;
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
});
const filteredUsers = response.data?.users.filter(
// @ts-expect-error
// @ts-ignore
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-expect-error
// @ts-ignore
setUsers(filteredUsers || []);
}
} catch (error) {
@@ -283,7 +282,6 @@ export const ImpersonationBar = () => {
<Shield className="h-4 w-4" />
Impersonate
</Button>
<DeleteAccountByEmail />
</div>
) : (
<div className="flex items-center gap-4 w-full flex-wrap">
@@ -1,8 +0,0 @@
import { Fraunces } from "next/font/google";
export const displayFont = Fraunces({
subsets: ["latin"],
weight: ["600"],
style: ["normal", "italic"],
display: "swap",
});
@@ -1,49 +0,0 @@
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 {}
};
@@ -1,296 +0,0 @@
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>
</>
);
};
@@ -1,133 +0,0 @@
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>
);
};
@@ -1,295 +0,0 @@
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>
);
};
@@ -1,188 +0,0 @@
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!,
);
type Tier = "hobby" | "startup";
const PLANS: {
tier: Tier;
name: string;
description: string;
price: number;
features: string[];
recommended?: boolean;
}[] = [
{
tier: "hobby",
name: "Hobby",
description: "For individual developers",
price: calculatePriceHobby(1, false),
features: [
"Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
],
},
{
tier: "startup",
name: "Startup",
description: "For small to mid-size teams",
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
recommended: true,
features: [
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
],
},
];
interface Props {
onNext: () => void;
}
export const PlanStep = ({ onNext }: Props) => {
const [loading, setLoading] = useState<
`${"trial" | "checkout"}:${Tier}` | 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: Tier) => {
if (!data) return;
const productId =
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
if (!productId) return;
setLoading(`checkout:${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");
setLoading(null);
}
};
const handleTrial = async (tier: Tier) => {
setLoading(`trial:${tier}`);
try {
await startFreeTrial({ tier });
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 {
setLoading(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">
Try any plan free for 7 days. No credit card required — add one only
if you decide to stay.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
{PLANS.map((plan) => {
const productId =
plan.tier === "hobby"
? data?.hobbyProductId
: data?.startupProductId;
return (
<div
key={plan.tier}
className="flex flex-col justify-between gap-6 bg-background p-7"
>
<div>
{plan.recommended && (
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
Recommended
</span>
)}
<p className="font-medium">{plan.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{plan.description}
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${plan.price.toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{plan.features.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>
<div className="flex flex-col gap-2">
<Button
isLoading={loading === `trial:${plan.tier}`}
disabled={loading !== null}
onClick={() => handleTrial(plan.tier)}
>
Start 7-day free trial
<ArrowRightIcon className="size-4" />
</Button>
<Button
variant="outline"
isLoading={loading === `checkout:${plan.tier}`}
disabled={loading !== null || !productId}
onClick={() => handleCheckout(plan.tier)}
>
Subscribe now
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};
@@ -1,118 +0,0 @@
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>
);
};
@@ -1,362 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import {
CheckCircle2,
CopyIcon,
InfoIcon,
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 className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">Requirements</span>
<span className="text-sm text-muted-foreground">
To ensure a smooth experience with Dokploy, your server should
have at least 1GB of RAM and 30GB of disk space.
</span>
</div>
</div>
</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 runs after the server boots and this page checks for it
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>
);
};
@@ -1,71 +0,0 @@
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">
It just takes 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,11 +1,9 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, 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,
@@ -16,7 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@@ -27,8 +24,6 @@ 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, {
@@ -42,24 +37,10 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props {
organizationId?: string;
children?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
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;
export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
@@ -84,18 +65,14 @@ export function AddOrganization({
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,
@@ -103,7 +80,6 @@ export function AddOrganization({
})
.then(() => {
form.reset();
setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@@ -123,94 +99,8 @@ export function AddOrganization({
});
};
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={(val) => {
if (!val) {
uploadCounter.current++;
setIsUploading(false);
}
setOpen(val);
}}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<Button
@@ -256,10 +146,8 @@ export function AddOrganization({
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="items-center gap-4">
<div className="flex items-center justify-between">
<FormLabel className="text-right">Name</FormLabel>
</div>
<FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormControl>
<Input
placeholder="Organization name"
@@ -274,78 +162,23 @@ export function AddOrganization({
<FormField
control={form.control}
name="logo"
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>
);
}}
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>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending || isUploading}>
<Button type="submit" isLoading={isPending}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
@@ -1,6 +1,5 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import {
@@ -9,14 +8,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const;
@@ -35,10 +26,6 @@ export const ShowOverviewDeployments = () => {
? router.query.subtab
: DEFAULT_SUBTAB;
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query;
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
onValueChange={setSubtab}
className="w-full min-w-0"
>
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
<TabsList className="self-start shrink-0">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
{subtab === "deployments" && (
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable
globalFilter={globalFilter}
statusFilter={statusFilter}
typeFilter={typeFilter}
/>
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
@@ -4,7 +4,6 @@ import {
AlertTriangle,
Bell,
CheckIcon,
Clock,
CreditCard,
FileText,
Loader2,
@@ -94,7 +93,6 @@ 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();
@@ -105,67 +103,7 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const { mutateAsync: startFreeTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async (
tier: "hobby" | "startup",
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: isAnnualPlan,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
utils.server.count.invalidate(),
]);
};
setSwitchingTrial(true);
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on ${planName}`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
.unwrap()
.catch(() => undefined);
setSwitchingTrial(false);
};
const handleStartTrial = async (tier: "hobby" | "startup") => {
setTrialTier(tier);
try {
await startFreeTrial({ tier });
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",
);
} finally {
setTrialTier(null);
}
};
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
const [startupServerQuantity, setStartupServerQuantity] = useState(
@@ -212,23 +150,12 @@ export const ShowBilling = () => {
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
const products = data?.products.filter((product) => {
// @ts-expect-error
// @ts-ignore
const interval = product?.default_price?.recurring?.interval;
return isAnnual ? interval === "year" : interval === "month";
});
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
const canStartTrial =
!isEnterpriseCloud &&
!!billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial;
/** Checkout would create a second subscription alongside a running trial. */
const canSubscribe =
!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 &&
!billingStatus?.isOnTrial;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -322,126 +249,6 @@ 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>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</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()}`
: ""
}${
billingStatus.hasPaymentMethod
? " · billing starts automatically"
: " · add a card to keep your servers"
}`
: "You're subscribed and billed automatically."}
</span>
</div>
</div>
{admin?.user.stripeCustomerId && (
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan &&
billingStatus.plan !== "legacy" && (
<>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
billingStatus.isAnnual,
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan as "hobby" | "startup",
!billingStatus.isAnnual,
billingStatus.isAnnual
? "monthly billing"
: "annual billing",
)
}
>
{billingStatus.isAnnual
? "Switch to monthly"
: "Switch to annual"}
</Button>
</>
)}
<Button
size="sm"
variant={
billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "default"
: "secondary"
}
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
{billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "Add payment method"
: "Manage Subscription"}
</Button>
</div>
)}
</div>
)}
{canStartTrial && (
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">
7-day free trial on any plan
</span>
<span className="text-sm text-muted-foreground">
No credit card required — pick Hobby or Startup below.
</span>
</div>
</div>
)}
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -1002,7 +809,7 @@ export const ShowBilling = () => {
"Unlimited Deployments",
"Unlimited Databases",
"Unlimited Applications",
"Setup 1 Server",
"1 Server Included",
"1 Organization",
"1 User",
"2 Environments",
@@ -1018,71 +825,68 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
{canSubscribe && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) =>
Math.max(1, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() => setHobbyServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
isLoading={trialTier === "hobby"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("hobby")}
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1137,7 +941,7 @@ export const ShowBilling = () => {
All the features of Hobby, plus…
</li>
{[
"Setup up to 3 Servers",
"3 Servers Included",
"3 Organizations",
"Unlimited Users",
"Unlimited Environments",
@@ -1155,83 +959,85 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
{canSubscribe && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
disabled={
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
className="w-full"
isLoading={trialTier === "startup"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("startup")}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
disabled={
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
Get Started
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity <
STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1423,17 +1229,31 @@ export const ShowBilling = () => {
</Button>
</div>
<div className="flex flex-col gap-2 mt-4 w-full">
{canSubscribe && (
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>
</section>
@@ -0,0 +1,61 @@
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>
</>
);
};
@@ -44,18 +44,6 @@ 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;
@@ -67,27 +55,12 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum([
"cloudflare",
"route53",
"porkbun",
"infomaniak",
"ovh",
]),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
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>;
@@ -100,10 +73,6 @@ const defaultValues: DnsProviderForm = {
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
endpoint: "ovh-eu",
applicationKey: "",
applicationSecret: "",
consumerKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -125,19 +94,6 @@ 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,
};
}
};
@@ -199,15 +155,6 @@ 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);
@@ -436,118 +383,6 @@ 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,9 +23,6 @@ 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 = () => {
@@ -1,214 +0,0 @@
import { Loader2, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const errorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : fallback;
export const DeleteAccount = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"request" | "confirm">("request");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [expiresInMinutes, setExpiresInMinutes] = useState(10);
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: hasPassword } = api.user.hasPassword.useQuery();
const requestCode = api.user.requestAccountDeletionCode.useMutation();
const deleteAccount = api.user.deleteAccount.useMutation();
const email = data?.user?.email ?? "";
const isBusy = requestCode.isPending || deleteAccount.isPending;
if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
return null;
}
const reset = () => {
setStep("request");
setPassword("");
setCode("");
};
const handleRequestCode = async () => {
try {
const result = await requestCode.mutateAsync(
hasPassword ? { password } : {},
);
setExpiresInMinutes(result.expiresInMinutes);
setCode("");
setStep("confirm");
toast.success(`We sent a confirmation code to ${email}`);
} catch (error) {
toast.error(errorMessage(error, "Error sending the confirmation code"));
}
};
const handleDelete = async () => {
try {
await deleteAccount.mutateAsync({ code });
toast.success("Your account has been deleted");
await authClient.signOut().catch(() => undefined);
router.push("/");
} catch (error) {
toast.error(errorMessage(error, "Error deleting account"));
}
};
return (
<Card className="bg-transparent border-destructive/40">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
<Trash2 className="size-5" />
Delete account
</CardTitle>
<CardDescription>
Permanently remove your account and all the data associated with it.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
<li>
Active subscriptions are cancelled immediately. Invoices already
issued are kept for accounting.
</li>
<li>
Every organization you own is deleted, including its projects,
services, environments, domains and server connections.
</li>
<li>
In organizations you belong to but do not own, only your membership,
API keys and the Git providers you connected are removed.
Applications using those providers keep running but must be
reconnected to redeploy.
</li>
<li>Containers already running on your servers are not touched.</li>
<li>
We will email a confirmation code to{" "}
<span className="font-medium text-foreground">{email}</span> before
anything is deleted.
</li>
<li>This action cannot be undone.</li>
</ul>
<div>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
{step === "request"
? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.`
: `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
{step === "request" && hasPassword && (
<>
<Label htmlFor="delete-account-password">Password</Label>
<Input
id="delete-account-password"
type="password"
autoComplete="current-password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</>
)}
{step === "confirm" && (
<>
<Label htmlFor="delete-account-code">
Confirmation code
</Label>
<Input
id="delete-account-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, ""))
}
/>
<Button
type="button"
variant="link"
size="sm"
className="self-start px-0"
disabled={isBusy}
onClick={handleRequestCode}
>
Resend code
</Button>
</>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
{step === "request" ? (
<Button
type="button"
variant="destructive"
disabled={isBusy || (hasPassword && password.length === 0)}
onClick={handleRequestCode}
>
{requestCode.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Send confirmation code
</Button>
) : (
<Button
type="button"
variant="destructive"
disabled={isBusy || code.length !== 6}
onClick={handleDelete}
>
{deleteAccount.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete account
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
};
@@ -1,4 +1,6 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -13,31 +15,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
children?: React.ReactNode;
asButton?: boolean;
}
export const ShowServerActions = ({ serverId, children }: Props) => {
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
{asButton ? (
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
}}
>
View Actions
</DropdownMenuItem>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
)}
<DialogContent
className="sm:max-w-xl"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>
<DialogDescription>Reload or clean the web server.</DialogDescription>
@@ -121,10 +121,7 @@ export const DeleteServerModal = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
interface Props {
serverId?: string;
children?: React.ReactNode;
asButton?: boolean;
}
export const HandleServers = ({ serverId, children }: Props) => {
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -141,19 +141,24 @@ export const HandleServers = ({ serverId, children }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : serverId ? (
<DialogTrigger asChild>
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Edit Server
</DropdownMenuItem>
</DialogTrigger>
)
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3">
@@ -162,10 +167,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-3xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription>
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
children?: React.ReactNode;
asButton?: boolean;
}
export const SetupServer = ({ serverId, children }: Props) => {
export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -81,19 +81,24 @@ export const SetupServer = ({ serverId, children }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
{asButton ? (
<DialogTrigger asChild>
<Button className="w-full cursor-pointer " size="sm">
Setup Server <Settings className="size-4" />
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent
className="sm:max-w-4xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
@@ -0,0 +1,28 @@
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>
);
};
@@ -7,27 +7,19 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
interface Props {
url: string;
token: string;
children?: React.ReactNode;
}
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
export const ShowMonitoringModal = ({ url, token }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} />
</div>
@@ -1,15 +1,11 @@
import { format } from "date-fns";
import {
Activity,
BarChartHorizontalBigIcon,
Clock,
Key,
KeyIcon,
Loader2,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -37,6 +33,7 @@ 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";
@@ -119,13 +116,10 @@ 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 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle
className="text-lg truncate min-w-0"
title={server.name}
>
<CardTitle className="text-lg wrap-break-word min-w-0">
{server.name}
</CardTitle>
</div>
@@ -228,19 +222,11 @@ export const ShowServers = () => {
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<SetupServer
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
className="w-full cursor-pointer "
size="sm"
>
Setup Server{" "}
<Settings className="size-4" />
</Button>
</TooltipTrigger>
</SetupServer>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
@@ -263,20 +249,22 @@ export const ShowServers = () => {
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Terminal className="h-4 w-4" />
</Button>
</TooltipTrigger>
</TerminalModal>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
@@ -284,19 +272,14 @@ export const ShowServers = () => {
)}
<Tooltip>
<HandleServers
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
</HandleServers>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
@@ -304,19 +287,14 @@ export const ShowServers = () => {
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<ShowServerActions
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Activity className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowServerActions>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
@@ -327,47 +305,61 @@ export const ShowServers = () => {
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowMonitoringModal>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
</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 && (
<Tooltip>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
<TooltipTrigger asChild>
<div>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
</DeleteServerModal>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DeleteServerModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete Server</p>
</TooltipContent>
@@ -0,0 +1,281 @@
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>
);
};
@@ -0,0 +1,190 @@
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>
);
};
@@ -0,0 +1,136 @@
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>
);
};
@@ -0,0 +1,179 @@
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,261 +1,441 @@
import { defineStepper } from "@stepperize/react";
import {
CheckIcon,
DatabaseIcon,
GitMergeIcon,
GlobeIcon,
UsersIcon,
BookIcon,
Code2,
Database,
GitMerge,
Globe,
Plug,
Puzzle,
Users,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import ConfettiExplosion from "react-confetti-explosion";
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 { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
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";
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 { 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 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 [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const router = useRouter();
const { push } = router;
// 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);
};
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
if (!confettiShown) {
setShowConfetti(true);
localStorage.setItem("hasShownConfetti", "true");
}
}, [showConfetti]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) close("/dashboard/settings/servers");
setIsOpen(open);
if (!open) {
const { success, ...rest } = router.query;
router.replace(
{ pathname: router.pathname, query: rest },
undefined,
{
shallow: true,
},
);
}
}}
>
<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",
)}
>
{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>
);
})}
</nav>
</Scoped>
<div className="py-2">
{stepper.switch({
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
project: () => (
<ProjectStep
plainTitle
onNext={(project) => {
setProjectId(project.projectId);
setEnvironmentId(project.environmentId);
goToNextVisible();
}}
/>
),
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
deploy: () => (
<DeployStep
plainTitle
environmentId={resolvedEnvironmentId}
onNext={goToNextVisible}
/>
),
complete: () => (
<CompleteIntro onFinish={() => close("/dashboard/home")} />
),
})}
<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>
{!isLast && (
<DialogFooter>
<Button
variant="ghost"
onClick={() => close("/dashboard/settings/servers")}
>
Skip for now
</Button>
</DialogFooter>
)}
<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"
}`}
/>
)}
</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>
);
},
})}
</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>
)}
<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();
}
}}
>
{stepper.isLast ? "Complete" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
@@ -124,11 +124,8 @@ export const AddInvitation = () => {
const mode = form.watch("mode");
useEffect(() => {
if (!open) {
form.reset();
setError(null);
}
}, [open, form, form.reset]);
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
@@ -228,7 +225,7 @@ export const AddInvitation = () => {
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
@@ -316,7 +313,7 @@ export const AddInvitation = () => {
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
@@ -40,7 +40,6 @@ 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",
@@ -62,7 +61,6 @@ const VaultProviderSchema = z
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -81,7 +79,6 @@ const VaultProviderSchema = z
region: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
parameterPath: z.string(),
serviceToken: z.string(),
awsEndpoint: z.string(),
vaultUri: z.string(),
@@ -139,8 +136,7 @@ const VaultProviderSchema = z
});
}
if (
(data.providerType === "aws" ||
data.providerType === "aws-parameter-store") &&
data.providerType === "aws" &&
data.awsEndpoint &&
!isValidUrl(data.awsEndpoint)
) {
@@ -150,17 +146,6 @@ 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 &&
@@ -215,11 +200,6 @@ 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"],
@@ -285,7 +265,6 @@ const defaultValues: VaultProviderForm = {
region: "",
accessKeyId: "",
secretAccessKey: "",
parameterPath: "",
serviceToken: "",
awsEndpoint: "",
vaultUri: "",
@@ -334,15 +313,6 @@ 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,
@@ -473,16 +443,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
environmentSlug: provider.config.environmentSlug,
secretPath: provider.config.secretPath,
}),
...((provider.config.providerType === "aws" ||
provider.config.providerType === "aws-parameter-store") && {
...(provider.config.providerType === "aws" && {
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 ?? "",
@@ -791,8 +757,7 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{(providerType === "aws" ||
providerType === "aws-parameter-store") && (
{providerType === "aws" && (
<>
<FormField
control={form.control}
@@ -850,44 +815,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</FormItem>
)}
/>
{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>
)}
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
</>
)}
@@ -18,7 +18,6 @@ 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",
@@ -45,8 +45,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-expect-error
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
addonFit.fit();
@@ -88,9 +89,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}, [id, serverId]);
return (
<div className="flex flex-col gap-4 h-full w-full">
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-transparent border rounded-lg p-2">
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
<div id={id} ref={termRef} className="rounded-xl" />
</div>
</div>
);
@@ -7,7 +7,6 @@ import {
Server,
Sparkles,
Stars,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -15,7 +14,6 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
@@ -128,13 +126,13 @@ export const UpdateServer = ({
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="max-w-lg" showCloseButton={false}>
<div className="flex items-center gap-2 mb-8">
<DialogTitle className="text-2xl font-semibold mr-auto">
<DialogContent className="max-w-lg">
<div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold">
Web Server Update
</DialogTitle>
{dokployVersion && (
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion}{" "}
@@ -143,12 +141,6 @@ 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 */}
@@ -1,245 +0,0 @@
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,32 +91,8 @@ 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,7 +608,6 @@ export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
"aws-parameter-store": AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
@@ -1,10 +1,8 @@
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";
import { useHubSpotChat } from "../shared/analytics";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
interface Props {
@@ -13,7 +11,6 @@ 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();
@@ -26,22 +23,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
useHubSpotChat(isChatEnabled);
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 (
<>
@@ -53,6 +34,12 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
+11 -18
View File
@@ -43,7 +43,6 @@ 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,
@@ -101,7 +100,6 @@ 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";
@@ -618,7 +616,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full min-w-0"}>
<SidebarMenuItem className={"w-full"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@@ -634,13 +632,14 @@ function SidebarLogo() {
>
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
"flex items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
"flex items-center justify-center rounded-sm border",
"size-6",
)}
>
<Logo
@@ -653,27 +652,22 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start min-w-0 flex-1",
"flex flex-col items-start",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5 min-w-0 w-full">
<TruncateTooltip
text={
activeOrganization?.name ?? "Select Organization"
}
className="text-sm font-medium"
/>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
<Badge variant="blue">Enterprise</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
className={cn("ml-auto", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>
@@ -1230,7 +1224,6 @@ 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">
@@ -83,11 +83,6 @@ const azureMapping: ClaimMapping = {
image: "",
};
// id: "sub",
// email: "preferred_username",
// emailVerified: "email_verified",
// name: "name",
const genericMapping: ClaimMapping = {
id: "sub",
email: "email",
@@ -233,9 +228,10 @@ export function RegisterOidcDialog({
mapping: {
id: oidc?.mapping?.id ?? baseMapping.id,
email: oidc?.mapping?.email ?? baseMapping.email,
emailVerified: oidc?.mapping?.emailVerified ?? "",
emailVerified:
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
name: oidc?.mapping?.name ?? baseMapping.name,
image: oidc?.mapping?.image ?? "",
image: oidc?.mapping?.image ?? baseMapping.image,
},
});
}, [data, open, form]);
@@ -0,0 +1,39 @@
"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,27 +388,6 @@ 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>
@@ -462,15 +441,32 @@ export function WhitelabelingSettings() {
</CardContent>
</Card>
{/* Links Section */}
{/* Metadata & Links Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Links</CardTitle>
<CardTitle>Metadata & Links</CardTitle>
<CardDescription>
Customize the footer text and sidebar links.
Customize the page title, 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,14 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};
@@ -92,11 +92,5 @@ export const useHubSpotChat = (enabled: boolean) => {
loadWidget,
];
}
return () => {
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
(callback) => callback !== loadWidget,
);
window.HubSpotConversations?.widget.remove();
};
}, [enabled]);
};
@@ -30,7 +30,6 @@ const dockerComposeServices = [
{ label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" },
{ label: "models", type: "keyword", info: "Define AI models" },
].map((opt) => ({
...opt,
apply: (
@@ -84,11 +83,6 @@ const dockerComposeServiceOptions = [
},
{ label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" },
{
label: "models",
type: "keyword",
info: "AI models to use, referencing top-level models",
},
].map((opt) => ({
...opt,
apply: (

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