Compare commits

..

4 Commits

Author SHA1 Message Date
Yash Kumar 05970e7b23 Merge pull request #5661 from manozzo/fix/template-card-tags-overflow
fix(templates): cap visible tags so the Create button stays visible

(cherry picked from commit d9fe046396)

[skip ci]
2026-10-15 15:29:26 +00:00
Dokploy Bot 187de463df chore: release v0.30.8 2026-09-29 08:07:45 +00:00
Mauricio Siu b4782724b5 Merge pull request #5538 from Dokploy/fix/hubspot-chat-widget
fix(cloud): load HubSpot chat widget for startup plan users

(cherry picked from commit d0ec74fbb8)

[skip ci]
2026-09-29 08:05:34 +00:00
Dokploy Bot 531b2c7232 chore: release v0.30.7 2026-09-18 09:04:35 +00:00
110 changed files with 4980 additions and 15160 deletions
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get version from package.json - 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 - name: Get latest GitHub tag
run: | run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) 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=$LATEST_TAG" >> $GITHUB_ENV
echo "$LATEST_TAG" echo $LATEST_TAG
- name: Compare versions - name: Compare versions
run: | run: |
if [ "$VERSION" != "$LATEST_TAG" ]; then if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true" VERSION_CHANGED="true"
else else
VERSION_CHANGED="false" VERSION_CHANGED="false"
fi fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV" echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:" echo "Comparing versions:"
echo "Current version: $VERSION" echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: $LATEST_TAG" echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED" echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists - name: Check if a PR already exists
run: | run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') 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: env:
GH_TOKEN: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary git push origin canary
gh pr create \ gh pr create \
--title "🚀 Release $VERSION" \ --title "🚀 Release ${{ env.VERSION }}" \
--body " --body '
This PR promotes changes from \`canary\` to \`main\` for version $VERSION. This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include: ### 🔍 Changes Include:
- Version bump to $VERSION - Version bump to ${{ env.VERSION }}
- All changes from canary branch - All changes from canary branch
### ✅ Pre-merge Checklist: ### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated - [ ] Documentation updated
- [ ] Docker images built and tested - [ ] 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 \ --base main \
--head canary \ --head canary \
--label "release" --label "automated pr" || true \ --label "release" --label "automated pr" || true \
+19 -28
View File
@@ -5,38 +5,33 @@ on:
branches: [main, canary] branches: [main, canary]
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build-and-push-cloud-image: build-and-push-cloud-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v3
with:
persist-credentials: false
- name: Set tag and version - name: Set tag and version
id: meta-cloud id: meta-cloud
run: | run: |
VERSION=$(jq -r .version apps/dokploy/package.json) VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else else
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./Dockerfile.cloud file: ./Dockerfile.cloud
@@ -53,28 +48,26 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v3
with:
persist-credentials: false
- name: Set tag and version - name: Set tag and version
id: meta-schedule id: meta-schedule
run: | run: |
VERSION=$(jq -r .version apps/dokploy/package.json) VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else else
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./Dockerfile.schedule file: ./Dockerfile.schedule
@@ -87,28 +80,26 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v3
with:
persist-credentials: false
- name: Set tag and version - name: Set tag and version
id: meta-server id: meta-server
run: | run: |
VERSION=$(jq -r .version apps/dokploy/package.json) VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else else
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT" echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./Dockerfile.server file: ./Dockerfile.server
+42 -60
View File
@@ -8,23 +8,18 @@ on:
env: env:
IMAGE_NAME: dokploy/dokploy IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs: jobs:
docker-amd: docker-amd:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -32,15 +27,15 @@ jobs:
- name: Set tag and version - name: Set tag and version
id: meta id: meta
run: | run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest" TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version") 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" TAG="canary"
else else
TAG="feature" TAG="feature"
fi fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file - name: Prepare env file
run: | run: |
@@ -48,7 +43,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
@@ -58,15 +53,13 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -75,15 +68,15 @@ jobs:
id: meta id: meta
run: | run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version") 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" TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version") 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" TAG="canary"
else else
TAG="feature" TAG="feature"
fi fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file - name: Prepare env file
run: | run: |
@@ -91,7 +84,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/arm64 platforms: linux/arm64
@@ -103,22 +96,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests - name: Create and push manifests
run: | run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version") VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest" TAG="latest"
@@ -130,7 +121,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64 ${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary" TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-amd64 \
@@ -147,37 +138,32 @@ jobs:
needs: [combine-manifests] needs: [combine-manifests]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
outputs: outputs:
version: ${{ steps.get_version.outputs.version }} version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }} npm_version: ${{ steps.get_version.outputs.npm_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Get version - name: Get version
id: get_version id: get_version
run: | run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version") VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT" echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh - name: Fetch install.sh
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
run: | run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh 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; } 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; } 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 mv install-pinned.sh install.sh
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.get_version.outputs.version }} tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
@@ -193,19 +179,15 @@ jobs:
needs: [generate-release] needs: [generate-release]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v4
with: with:
version: 10.22.0 version: 10.22.0
- uses: actions/setup-node@v7 - uses: actions/setup-node@v4
with: with:
node-version: 24.4.0 node-version: 24.4.0
cache: pnpm 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 git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /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 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 install
pnpm run generate pnpm run generate
git config user.name "Dokploy Bot" git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com" git config user.email "bot@dokploy.com"
git add -A git add -A
git commit -m "chore: bump version to $NPM_VERSION" \ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty --allow-empty
git push 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 - name: Sync version to CLI repository
run: | run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /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 mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install pnpm install
pnpm run generate pnpm run generate
git config user.name "Dokploy Bot" git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com" git config user.email "bot@dokploy.com"
git add -A git add -A
git commit -m "chore: bump version to $NPM_VERSION" \ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty --allow-empty
git push 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 - name: Sync version to SDK repository
run: | run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /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 mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install pnpm install
pnpm run generate pnpm run generate
git config user.name "Dokploy Bot" git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com" git config user.email "bot@dokploy.com"
git add -A git add -A
git commit -m "chore: bump version to $NPM_VERSION" \ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty --allow-empty
git push 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: pull_request:
branches: [canary] branches: [canary]
permissions:
contents: read
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup biomeJs - name: Setup biomeJs
uses: biomejs/setup-biome@v2 uses: biomejs/setup-biome@v2
- name: Run Biome formatter - name: Run Biome formatter
run: biome format --write run: biome format --write
+2 -4
View File
@@ -16,19 +16,17 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout main - name: Checkout main
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }} token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main - name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: | run: |
git config user.name "Dokploy Bot" git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com" 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 if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA" git cherry-pick -x -m 1 "$SHA"
else else
+1 -5
View File
@@ -7,16 +7,12 @@ concurrency:
group: hotfix-to-main group: hotfix-to-main
cancel-in-progress: false cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout main - name: Checkout main
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
ref: main ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }} token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+19 -28
View File
@@ -7,23 +7,18 @@ on:
env: env:
IMAGE_NAME: dokploy/monitoring IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs: jobs:
docker-amd: docker-amd:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -31,17 +26,17 @@ jobs:
- name: Set tag - name: Set tag
id: meta id: meta
run: | run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest" TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary" TAG="canary"
else else
TAG="feature" TAG="feature"
fi fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile.monitoring file: ./Dockerfile.monitoring
@@ -52,15 +47,13 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,17 +61,17 @@ jobs:
- name: Set - name: Set
id: meta id: meta
run: | run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest" TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary" TAG="canary"
else else
TAG="feature" TAG="feature"
fi fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile.monitoring file: ./Dockerfile.monitoring
@@ -91,29 +84,27 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests - name: Create and push manifests
run: | run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest" TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64 ${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary" TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-amd64 \
+3 -5
View File
@@ -14,11 +14,9 @@ jobs:
matrix: matrix:
job: [build, test, typecheck] job: [build, test, typecheck]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v5
with: - uses: pnpm/action-setup@v5
persist-credentials: false - uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with: with:
node-version: 24.4.0 node-version: 24.4.0
cache: "pnpm" cache: "pnpm"
+4 -7
View File
@@ -12,20 +12,17 @@ on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
generate-and-commit: generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Dokploy repository - name: Checkout Dokploy repository
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
persist-credentials: false token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v7 - uses: actions/setup-node@v4
with: with:
node-version: 24.4.0 node-version: 24.4.0
cache: "pnpm" cache: "pnpm"
@@ -28,9 +28,6 @@ env:
DOKPLOY_SERVICE: dokploy DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000 DOKPLOY_PORT: 3000
permissions:
contents: read
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
jobs: jobs:
@@ -424,10 +421,8 @@ jobs:
# ── Job summary ──────────────────────────────────────────────────────── # ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary - name: Write job summary
if: always() if: always()
env:
JOB_STATUS: ${{ job.status }}
run: | run: |
STATUS="$JOB_STATUS" STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^} ## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
@@ -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 { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => { describe("redactRcloneCredentials (#4621)", () => {
@@ -50,48 +48,3 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]"); 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");
});
});
@@ -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" }),
);
},
);
});
-71
View File
@@ -472,77 +472,6 @@ describe("infisical client", () => {
expect(params.get("secretPath")).toBe("/frontend"); 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 () => { it("throws a clear error for a missing secret", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(loginResponse()) .mockResolvedValueOnce(loginResponse())
@@ -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");
});
});
@@ -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"]);
});
});
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div className="flex flex-col gap-1"> <div>
<CardTitle className="text-xl">Patches</CardTitle> <CardTitle>Patches</CardTitle>
<CardDescription> <CardDescription>
Apply code patches to your repository during build. Patches are Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building. applied after cloning the repository and before building.
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
}; };
return ( return (
<Card className="bg-background h-full min-h-[50vh]"> <Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between"> <CardHeader className="px-0">
<div className="flex flex-col gap-1"> <div className="flex justify-between items-center gap-y-2 flex-wrap">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle> <div className="flex flex-col gap-2">
<CardDescription> <CardTitle className="text-xl font-bold flex items-center gap-2">
Schedule tasks to run automatically at specified intervals. Scheduled Tasks
</CardDescription> </CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</div> </div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-0">
{isLoadingSchedules ? ( {isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]"> <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" /> <Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
}; };
return ( return (
<Card className="bg-background h-full min-h-[50vh]"> <Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between"> <CardHeader className="px-0">
<div className="flex flex-col gap-1"> <div className="flex justify-between items-center flex-wrap gap-2">
<CardTitle className="text-xl">Volume Backups</CardTitle> <div className="flex flex-col gap-2">
<CardDescription> <CardTitle className="text-xl font-bold flex items-center gap-2">
Schedule volume backups to run automatically at specified intervals Volume Backups
</CardDescription> </CardTitle>
</div> <CardDescription>
<div className="flex items-center gap-2 flex-wrap"> Schedule volume backups to run automatically at specified
{volumeBackups && volumeBackups.length > 0 && ( intervals
<> </CardDescription>
<HandleVolumeBackups id={id} volumeBackupType={type} /> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} /> {volumeBackups && volumeBackups.length > 0 && (
</div> <>
</> <HandleVolumeBackups id={id} volumeBackupType={type} />
)} <div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-0">
{isLoadingVolumeBackups ? ( {isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]"> <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" /> <Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -22,7 +22,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
interface Props { interface Props {
@@ -31,7 +30,6 @@ interface Props {
const AddRedirectSchema = z.object({ const AddRedirectSchema = z.object({
command: z.string(), command: z.string(),
pullImages: z.boolean(),
}); });
type AddCommand = z.infer<typeof AddRedirectSchema>; type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({ const form = useForm<AddCommand>({
defaultValues: { defaultValues: {
command: "", command: "",
pullImages: false,
}, },
resolver: zodResolver(AddRedirectSchema), resolver: zodResolver(AddRedirectSchema),
}); });
useEffect(() => { useEffect(() => {
if (data) { if (data?.command) {
form.reset({ form.reset({
command: data.command || "", command: data?.command || "",
pullImages: data.pullImages ?? false,
}); });
} }
}, [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({ await mutateAsync({
composeId, composeId,
command: formData.command, command: data?.command,
pullImages: formData.pullImages,
}) })
.then(async () => { .then(async () => {
toast.success("Command Updated"); toast.success("Command Updated");
@@ -114,30 +109,6 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>. <strong>docker</strong>.
</AlertBlock> </AlertBlock>
<div className="flex flex-col gap-4"> <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 <FormField
control={form.control} control={form.control}
name="command" 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>
);
};
@@ -1,4 +1,11 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && ( {canDeploy && (
<DialogAction <DialogAction
title="Rebuild Compose" title="Rebuild Compose"
@@ -158,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath, composePath: data.composePath,
composeId, composeId,
sourceType: "bitbucket", sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules, enableSubmodules: data.enableSubmodules,
}) })
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId, composeId,
sourceType: "git", sourceType: "git",
composePath: values.composePath, composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [], watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules, enableSubmodules: values.enableSubmodules,
}) })
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId, giteaId: data.giteaId,
composeId, composeId,
sourceType: "gitea", sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules, enableSubmodules: data.enableSubmodules,
} as any) } as any)
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath, composePath: data.composePath,
githubId: data.githubId, githubId: data.githubId,
sourceType: "github", sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules, enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType, triggerType: data.triggerType,
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id, gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace, gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab", sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules, 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 Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -95,9 +101,12 @@ export const ShowBackups = ({
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between"> <CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<CardTitle className="text-xl">Backups</CardTitle> <CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardDescription> <CardDescription>
Add backups to your database to save the data to a different Add backups to your database to save the data to a different
provider. provider.
@@ -1,8 +1,10 @@
"use client"; "use client";
import { import {
type ColumnFiltersState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
type PaginationState, type PaginationState,
@@ -25,6 +27,7 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
return null; return null;
} }
interface ShowDeploymentsTableProps { export function ShowDeploymentsTable() {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true }, { 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>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 50, pageSize: 50,
@@ -450,140 +446,177 @@ export function ShowDeploymentsTable({
columns, columns,
state: { state: {
sorting, sorting,
columnFilters,
globalFilter,
pagination, pagination,
}, },
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination, onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
}); });
return ( return (
<div className="px-0"> <div className="space-y-2">
{isLoading ? ( <div className="flex flex-wrap items-center gap-2">
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground"> <Input
<Loader2 className="size-4 animate-spin" /> placeholder="Search by name, project, environment, server..."
<span>Loading deployments...</span> value={globalFilter}
</div> onChange={(e) => setGlobalFilter(e.target.value)}
) : ( className="max-w-xs"
<> />
<div className="rounded-md border overflow-x-auto"> <Select value={statusFilter} onValueChange={setStatusFilter}>
<Table> <SelectTrigger className="w-[140px]">
<TableHeader> <SelectValue placeholder="Status" />
{table.getHeaderGroups().map((headerGroup) => ( </SelectTrigger>
<TableRow key={headerGroup.id}> <SelectContent>
{headerGroup.headers.map((header) => ( <SelectItem value="all">All statuses</SelectItem>
<TableHead key={header.id}> <SelectItem value="running">Running</SelectItem>
{header.isPlaceholder <SelectItem value="done">Done</SelectItem>
? null <SelectItem value="error">Error</SelectItem>
: flexRender( <SelectItem value="cancelled">Cancelled</SelectItem>
header.column.columnDef.header, </SelectContent>
header.getContext(), </Select>
)} <Select value={typeFilter} onValueChange={setTypeFilter}>
</TableHead> <SelectTrigger className="w-[140px]">
))} <SelectValue placeholder="Type" />
</TableRow> </SelectTrigger>
))} <SelectContent>
</TableHeader> <SelectItem value="all">All types</SelectItem>
<TableBody> <SelectItem value="application">Application</SelectItem>
{table.getRowModel().rows?.length ? ( <SelectItem value="compose">Compose</SelectItem>
table.getRowModel().rows.map((row) => ( </SelectContent>
<TableRow key={row.id}> </Select>
{row.getVisibleCells().map((cell) => ( </div>
<TableCell key={cell.id}> <div className="px-0">
{flexRender( {isLoading ? (
cell.column.columnDef.cell, <div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
cell.getContext(), <Loader2 className="size-4 animate-spin" />
)} <span>Loading deployments...</span>
</TableCell> </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>
))
) : (
<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> </TableHeader>
</Select> <TableBody>
<span className="text-sm text-muted-foreground whitespace-nowrap"> {table.getRowModel().rows?.length ? (
Showing{" "} table.getRowModel().rows.map((row) => (
{filteredData.length === 0 <TableRow key={row.id}>
? 0 {row.getVisibleCells().map((cell) => (
: pagination.pageIndex * pagination.pageSize + 1}{" "} <TableCell key={cell.id}>
to{" "} {flexRender(
{Math.min( cell.column.columnDef.cell,
(pagination.pageIndex + 1) * pagination.pageSize, cell.getContext(),
filteredData.length, )}
)}{" "} </TableCell>
of {filteredData.length} entries ))}
</span> </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>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<Button <div className="flex items-center gap-2 flex-wrap">
variant="outline" <span className="text-sm text-muted-foreground whitespace-nowrap">
size="sm" Rows per page
className="h-8" </span>
onClick={() => table.previousPage()} <Select
disabled={!table.getCanPreviousPage()} value={String(pagination.pageSize)}
> onValueChange={(value) => {
<ChevronLeft className="size-4" /> setPagination((p) => ({
Previous ...p,
</Button> pageSize: Number(value),
<Button pageIndex: 0,
variant="outline" }));
size="sm" }}
className="h-8" >
onClick={() => table.nextPage()} <SelectTrigger className="h-8 w-[70px]">
disabled={!table.getCanNextPage()} <SelectValue />
> </SelectTrigger>
Next <SelectContent side="top">
<ChevronRight className="size-4" /> {[10, 25, 50, 100].map((size) => (
</Button> <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>
</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 { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { DeleteAccountByEmail } from "./delete-account-by-email";
type User = typeof authClient.$Infer.Session.user; type User = typeof authClient.$Infer.Session.user;
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
}); });
const filteredUsers = response.data?.users.filter( const filteredUsers = response.data?.users.filter(
// @ts-expect-error // @ts-ignore
(user) => user.allowImpersonation && data?.user?.email !== user.email, (user) => user.allowImpersonation && data?.user?.email !== user.email,
); );
if (!response.error) { if (!response.error) {
// @ts-expect-error // @ts-ignore
setUsers(filteredUsers || []); setUsers(filteredUsers || []);
} }
} catch (error) { } catch (error) {
@@ -283,7 +282,6 @@ export const ImpersonationBar = () => {
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
Impersonate Impersonate
</Button> </Button>
<DeleteAccountByEmail />
</div> </div>
) : ( ) : (
<div className="flex items-center gap-4 w-full flex-wrap"> <div className="flex items-center gap-4 w-full flex-wrap">
@@ -1,6 +1,5 @@
import { Rocket } from "lucide-react"; import { Rocket } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import { import {
@@ -9,14 +8,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const; const SUBTAB_VALUES = ["deployments", "queue"] as const;
@@ -35,10 +26,6 @@ export const ShowOverviewDeployments = () => {
? router.query.subtab ? router.query.subtab
: DEFAULT_SUBTAB; : DEFAULT_SUBTAB;
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const setSubtab = (value: string) => { const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return; if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query; const { subtab: _current, ...query } = router.query;
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
onValueChange={setSubtab} onValueChange={setSubtab}
className="w-full min-w-0" 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="mt-2">
<TabsList className="self-start shrink-0"> <TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger> <TabsTrigger value="queue">Queue</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger> </TabsList>
</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>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4"> <TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable <ShowDeploymentsTable />
globalFilter={globalFilter}
statusFilter={statusFilter}
typeFilter={typeFilter}
/>
</TabsContent> </TabsContent>
<TabsContent value="queue" className="mt-0 pt-4"> <TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable /> <ShowQueueTable />
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{viewMode === "detailed" && {viewMode === "detailed" &&
template?.tags?.length > 0 && ( template?.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
{template?.tags?.map((tag) => ( {template.tags.slice(0, 6).map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="green" variant="green"
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{tag} {tag}
</Badge> </Badge>
))} ))}
{template.tags.length > 6 && (
<Badge
variant="green"
className="text-[10px] px-2 py-0"
title={template.tags.slice(6).join(", ")}
>
+{template.tags.length - 6}
</Badge>
)}
</div> </div>
)} )}
</div> </div>
@@ -111,20 +111,14 @@ export const ShowBilling = () => {
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false); const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async ( const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
tier: "hobby" | "startup", const planName = tier === "startup" ? "Startup" : "Hobby";
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => { const switchPlan = async () => {
await upgradeSubscription({ await upgradeSubscription({
tier, tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: isAnnualPlan, isAnnual: false,
}); });
await new Promise((resolve) => setTimeout(resolve, 3000)); await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([ await Promise.all([
@@ -139,7 +133,7 @@ export const ShowBilling = () => {
await toast await toast
.promise(switchPlan(), { .promise(switchPlan(), {
loading: `Switching to ${planName}...`, loading: `Switching to ${planName}...`,
success: `Your trial is now on ${planName}`, success: `Your trial is now on the ${planName} plan`,
error: (error) => error: (error) =>
error instanceof Error ? error.message : "Error switching plan", error instanceof Error ? error.message : "Error switching plan",
}) })
@@ -342,11 +336,6 @@ export const ShowBilling = () => {
<Badge className="capitalize" variant="secondary"> <Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"} {billingStatus.plan ?? "Trial"}
</Badge> </Badge>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</Badge>
)}
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial {billingStatus.isOnTrial
@@ -367,45 +356,23 @@ export const ShowBilling = () => {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial && {billingStatus.isOnTrial &&
useNewPricing && useNewPricing &&
billingStatus.plan && billingStatus.plan && (
billingStatus.plan !== "legacy" && ( <Button
<> size="sm"
<Button variant="outline"
size="sm" isLoading={switchingTrial}
variant="outline" onClick={() =>
isLoading={switchingTrial} handleSwitchTrialPlan(
onClick={() => billingStatus.plan === "startup"
handleSwitchTrialPlan( ? "hobby"
billingStatus.plan === "startup" : "startup",
? "hobby" )
: "startup", }
billingStatus.isAnnual, >
) {billingStatus.plan === "startup"
} ? "Switch to Hobby"
> : "Switch to Startup"}
{billingStatus.plan === "startup" </Button>
? "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 <Button
size="sm" size="sm"
@@ -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 { useState } from "react";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -13,31 +15,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props { interface Props {
serverId: string; 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); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? ( {asButton ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild> <DialogTrigger asChild>
<DropdownMenuItem <Button variant="outline" size="icon" className="h-9 w-9">
className="w-full cursor-pointer" <Activity className="h-4 w-4" />
onSelect={(e) => { </Button>
e.preventDefault();
}}
>
View Actions
</DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
)} )}
<DialogContent <DialogContent className="sm:max-w-xl">
className="sm:max-w-xl"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle> <DialogTitle className="text-xl">Web server settings</DialogTitle>
<DialogDescription>Reload or clean the web server.</DialogDescription> <DialogDescription>Reload or clean the web server.</DialogDescription>
@@ -121,10 +121,7 @@ export const DeleteServerModal = ({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent className="max-w-lg">
className="max-w-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Server</DialogTitle> <DialogTitle>Delete Server</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
interface Props { interface Props {
serverId?: string; 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 utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } = const { data: canCreateMoreServers, refetch } =
@@ -141,19 +141,24 @@ export const HandleServers = ({ serverId, children }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? ( {serverId ? (
<DialogTrigger asChild>{children}</DialogTrigger> asButton ? (
) : serverId ? ( <DialogTrigger asChild>
<DialogTrigger asChild> <Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => { onSelect={(e) => {
e.preventDefault(); e.preventDefault();
setIsOpen(true);
}} }}
> >
Edit Server Edit Server
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )
) : ( ) : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer space-x-3"> <Button className="cursor-pointer space-x-3">
@@ -162,10 +167,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
)} )}
<DialogContent <DialogContent className="sm:max-w-3xl ">
className="sm:max-w-3xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle> <DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props { interface Props {
serverId: string; 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 [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery( const { data: server } = api.server.one.useQuery(
{ {
@@ -81,19 +81,24 @@ export const SetupServer = ({ serverId, children }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? ( {asButton ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-full cursor-pointer " size="sm"> <Button variant="outline" size="icon" className="h-9 w-9">
Setup Server <Settings className="size-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server <Settings className="size-4" />
</Button>
)} )}
<DialogContent <DialogContent className="sm:max-w-4xl ">
className="sm:max-w-4xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
@@ -7,27 +7,19 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
interface Props { interface Props {
url: string; url: string;
token: string; token: string;
children?: React.ReactNode;
} }
export const ShowMonitoringModal = ({ url, token, children }: Props) => { export const ShowMonitoringModal = ({ url, token }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? ( <DialogTrigger asChild>
<DialogTrigger asChild>{children}</DialogTrigger> <Button variant="outline" size="icon" className="h-9 w-9">
) : ( <BarChartHorizontalBigIcon className="h-4 w-4" />
<DialogTrigger asChild> </Button>
<Button variant="outline" size="icon" className="h-9 w-9"> </DialogTrigger>
<BarChartHorizontalBigIcon className="h-4 w-4" /> <DialogContent className="sm:max-w-7xl ">
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex gap-4 py-4 w-full"> <div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} /> <ShowPaidMonitoring BASE_URL={url} token={token} />
</div> </div>
@@ -1,15 +1,11 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
Activity,
BarChartHorizontalBigIcon,
Clock, Clock,
Key, Key,
KeyIcon, KeyIcon,
Loader2, Loader2,
Network, Network,
Pencil,
ServerIcon, ServerIcon,
Settings,
Terminal, Terminal,
Trash2, Trash2,
User, User,
@@ -228,19 +224,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 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<Tooltip> <Tooltip>
<SetupServer <TooltipTrigger asChild>
serverId={server.serverId} <SetupServer
> serverId={server.serverId}
<TooltipTrigger asChild> />
<Button </TooltipTrigger>
className="w-full cursor-pointer "
size="sm"
>
Setup Server{" "}
<Settings className="size-4" />
</Button>
</TooltipTrigger>
</SetupServer>
<TooltipContent <TooltipContent
className="max-w-xs" className="max-w-xs"
side="bottom" side="bottom"
@@ -263,20 +251,22 @@ export const ShowServers = () => {
{server.sshKeyId && {server.sshKeyId &&
permissions?.server.terminal && ( permissions?.server.terminal && (
<Tooltip> <Tooltip>
<TerminalModal <TooltipTrigger asChild>
serverId={server.serverId} <div>
asButton={true} <TerminalModal
> serverId={server.serverId}
<TooltipTrigger asChild> asButton={true}
<Button
variant="outline"
size="icon"
className="h-9 w-9"
> >
<Terminal className="h-4 w-4" /> <Button
</Button> variant="outline"
</TooltipTrigger> size="icon"
</TerminalModal> className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Terminal</p> <p>Terminal</p>
</TooltipContent> </TooltipContent>
@@ -284,19 +274,14 @@ export const ShowServers = () => {
)} )}
<Tooltip> <Tooltip>
<HandleServers <TooltipTrigger asChild>
serverId={server.serverId} <div>
> <HandleServers
<TooltipTrigger asChild> serverId={server.serverId}
<Button asButton={true}
variant="outline" />
size="icon" </div>
className="h-9 w-9" </TooltipTrigger>
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
</HandleServers>
<TooltipContent> <TooltipContent>
<p>Edit Server</p> <p>Edit Server</p>
</TooltipContent> </TooltipContent>
@@ -304,19 +289,14 @@ export const ShowServers = () => {
{server.sshKeyId && !isBuildServer && ( {server.sshKeyId && !isBuildServer && (
<Tooltip> <Tooltip>
<ShowServerActions <TooltipTrigger asChild>
serverId={server.serverId} <div>
> <ShowServerActions
<TooltipTrigger asChild> serverId={server.serverId}
<Button asButton={true}
variant="outline" />
size="icon" </div>
className="h-9 w-9" </TooltipTrigger>
>
<Activity className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowServerActions>
<TooltipContent> <TooltipContent>
<p>Web Server Actions</p> <p>Web Server Actions</p>
</TooltipContent> </TooltipContent>
@@ -327,23 +307,17 @@ export const ShowServers = () => {
server.sshKeyId && server.sshKeyId &&
!isBuildServer && ( !isBuildServer && (
<Tooltip> <Tooltip>
<ShowMonitoringModal <TooltipTrigger asChild>
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`} <div>
token={ <ShowMonitoringModal
server?.metricsConfig?.server url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
?.token token={
} server?.metricsConfig
> ?.server?.token
<TooltipTrigger asChild> }
<Button />
variant="outline" </div>
size="icon" </TooltipTrigger>
className="h-9 w-9"
>
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowMonitoringModal>
<TooltipContent> <TooltipContent>
<p>Monitoring</p> <p>Monitoring</p>
</TooltipContent> </TooltipContent>
@@ -354,20 +328,22 @@ export const ShowServers = () => {
{permissions?.server.delete && ( {permissions?.server.delete && (
<Tooltip> <Tooltip>
<DeleteServerModal <TooltipTrigger asChild>
serverId={server.serverId} <div>
serverName={server.name} <DeleteServerModal
> serverId={server.serverId}
<TooltipTrigger asChild> serverName={server.name}
<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
</Button> variant="ghost"
</TooltipTrigger> size="icon"
</DeleteServerModal> 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> <TooltipContent>
<p>Delete Server</p> <p>Delete Server</p>
</TooltipContent> </TooltipContent>
@@ -124,11 +124,8 @@ export const AddInvitation = () => {
const mode = form.watch("mode"); const mode = form.watch("mode");
useEffect(() => { useEffect(() => {
if (!open) { form.reset();
form.reset(); }, [form, form.formState.isSubmitSuccessful, form.reset]);
setError(null);
}
}, [open, form, form.reset]);
useEffect(() => { useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") { if (isCloud && form.getValues("mode") === "credentials") {
@@ -228,7 +225,7 @@ export const AddInvitation = () => {
<FormLabel>Invite Method</FormLabel> <FormLabel>Invite Method</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} defaultValue={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -316,7 +313,7 @@ export const AddInvitation = () => {
<FormLabel>Email Provider</FormLabel> <FormLabel>Email Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} defaultValue={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -45,8 +45,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
term.loadAddon(clipboardAddon); term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term); fixMacOsAltKeys(term);
// @ts-expect-error // @ts-ignore
term.open(termRef.current); term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit); term.loadAddon(addonFit);
addonFit.fit(); addonFit.fit();
@@ -88,9 +89,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}, [id, serverId]); }, [id, serverId]);
return ( 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 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>
</div> </div>
); );
@@ -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}
/>
</>
);
};
@@ -30,7 +30,6 @@ const dockerComposeServices = [
{ label: "networks", type: "keyword", info: "Define networks" }, { label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" }, { label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" }, { label: "secrets", type: "keyword", info: "Define secrets" },
{ label: "models", type: "keyword", info: "Define AI models" },
].map((opt) => ({ ].map((opt) => ({
...opt, ...opt,
apply: ( apply: (
@@ -84,11 +83,6 @@ const dockerComposeServiceOptions = [
}, },
{ label: "restart", type: "keyword", info: "Restart policy" }, { label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" }, { label: "networks", type: "keyword", info: "Networks to join" },
{
label: "models",
type: "keyword",
info: "AI models to use, referencing top-level models",
},
].map((opt) => ({ ].map((opt) => ({
...opt, ...opt,
apply: ( apply: (
@@ -35,16 +35,6 @@
"additionalProperties": false "additionalProperties": false
}, },
"models": {
"id": "#/properties/models",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/model"
}
}
},
"networks": { "networks": {
"id": "#/properties/networks", "id": "#/properties/networks",
"type": "object", "type": "object",
@@ -345,30 +335,6 @@
"mem_swappiness": { "type": ["integer", "string"] }, "mem_swappiness": { "type": ["integer", "string"] },
"memswap_limit": { "type": ["number", "string"] }, "memswap_limit": { "type": ["number", "string"] },
"network_mode": { "type": "string" }, "network_mode": { "type": "string" },
"models": {
"oneOf": [
{ "$ref": "#/definitions/list_of_strings" },
{
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"oneOf": [
{
"type": "object",
"properties": {
"endpoint_var": { "type": "string" },
"model_var": { "type": "string" }
},
"additionalProperties": false,
"patternProperties": { "^x-": {} }
},
{ "type": "null" }
]
}
}
}
]
},
"networks": { "networks": {
"oneOf": [ "oneOf": [
{ "$ref": "#/definitions/list_of_strings" }, { "$ref": "#/definitions/list_of_strings" },
@@ -924,23 +890,6 @@
"patternProperties": { "^x-": {} } "patternProperties": { "^x-": {} }
}, },
"model": {
"id": "#/definitions/model",
"type": "object",
"properties": {
"name": { "type": "string" },
"model": { "type": "string" },
"context_size": { "type": "integer" },
"runtime_flags": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["model"],
"additionalProperties": false,
"patternProperties": { "^x-": {} }
},
"command": { "command": {
"oneOf": [ "oneOf": [
{ "type": "null" }, { "type": "null" },
@@ -1 +0,0 @@
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1373,13 +1373,6 @@
"when": 1788867370653, "when": 1788867370653,
"tag": "0195_classy_whirlwind", "tag": "0195_classy_whirlwind",
"breakpoints": true "breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
} }
] ]
} }
-25
View File
@@ -1,25 +0,0 @@
export const isHttpsRequest = (
forwardedProto: string | string[] | undefined,
): boolean => {
if (!forwardedProto) return false;
const value = Array.isArray(forwardedProto)
? forwardedProto[0]
: forwardedProto;
// take the first value in case a proxy chain sent a list
return value?.split(",")[0]?.trim().toLowerCase() === "https";
};
const hasSecureAttribute = (cookie: string): boolean =>
/;\s*secure(?:\s*;|\s*$)/i.test(cookie);
const withSecure = (cookie: string): string =>
hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`;
// Add Secure to a Set-Cookie header value (string or string[]).
export const secureSetCookie = (
value: string | number | readonly string[],
): string | number | string[] => {
if (typeof value === "number") return value;
if (Array.isArray(value)) return value.map(withSecure);
return withSecure(value as string);
};
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.30.6", "version": "v0.30.8",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -109,7 +109,7 @@
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"micromatch": "4.0.8", "micromatch": "4.0.8",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"next": "16.3.4", "next": "16.3.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1", "node-os-utils": "2.0.1",
@@ -133,7 +133,7 @@
"resend": "^6.0.2", "resend": "^6.0.2",
"semver": "7.7.3", "semver": "7.7.3",
"shadcn": "^4.11.0", "shadcn": "^4.11.0",
"shell-quote": "^1.8.4", "shell-quote": "^1.8.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"ssh2": "~1.16.0", "ssh2": "~1.16.0",
@@ -180,7 +180,7 @@
"tsx": "^4.22.4", "tsx": "^4.22.4",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite-tsconfig-paths": "4.3.2", "vite-tsconfig-paths": "4.3.2",
"vitest": "^4.1.0" "vitest": "^4.0.18"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.25.2" "initVersion": "7.25.2"
+1 -24
View File
@@ -1,30 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { auth } from "@dokploy/server/index"; import { auth } from "@dokploy/server/index";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
// Disallow body parsing, we will parse it manually // Disallow body parsing, we will parse it manually
export const config = { api: { bodyParser: false } }; export const config = { api: { bodyParser: false } };
const handler = toNodeHandler(auth.handler); export default toNodeHandler(auth.handler);
// Mark the session cookie as Secure when the request comes over HTTPS
export default function authHandler(req: IncomingMessage, res: ServerResponse) {
if (isHttpsRequest(req.headers["x-forwarded-proto"])) {
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? setHeader(name, secureSetCookie(value))
: setHeader(name, value);
const appendHeader = res.appendHeader?.bind(res);
if (appendHeader) {
res.appendHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? appendHeader(name, secureSetCookie(value) as string | string[])
: appendHeader(name, value);
}
}
return handler(req, res);
}
@@ -32,14 +32,6 @@ const getPackageVersion = (headers: any, body: any) => {
return null; return null;
}; };
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
@@ -15,14 +15,6 @@ import {
logWebhookError, logWebhookError,
} from "../[refreshToken]"; } from "../[refreshToken]";
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
-8
View File
@@ -26,14 +26,6 @@ import {
const getGithubRepositoryOwner = (githubBody: any) => const getGithubRepositoryOwner = (githubBody: any) =>
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login; githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
+10 -4
View File
@@ -158,9 +158,11 @@ export default async function handler(
newSubscription.customer as string, newSubscription.customer as string,
); );
// The account may already be gone when the deletion flow cancelled if (!admin) {
// the subscription; nothing left to disable. return res.status(400).send("Webhook Error: Admin not found");
if (!admin || admin.isEnterpriseCloud) { }
if (admin.isEnterpriseCloud) {
break; break;
} }
@@ -312,7 +314,11 @@ export default async function handler(
const customer = event.data.object as Stripe.Customer; const customer = event.data.object as Stripe.Customer;
const admin = await findUserByStripeCustomerId(customer.id); const admin = await findUserByStripeCustomerId(customer.id);
if (!admin || admin.isEnterpriseCloud) { if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
break; break;
} }
@@ -36,7 +36,6 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks"; import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -195,13 +194,6 @@ const Service = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdateApplication applicationId={applicationId} /> <UpdateApplication applicationId={applicationId} />
)} )}
{permissions?.service.create && (
<TransferService
id={applicationId}
type="application"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={applicationId} type="application" /> <DeleteService id={applicationId} type="application" />
)} )}
@@ -23,7 +23,6 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups"; import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command"; import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation"; import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers"; import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -34,7 +33,6 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring"; import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring"; import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks"; import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -186,13 +184,6 @@ const Service = (
<UpdateCompose composeId={composeId} /> <UpdateCompose composeId={composeId} />
)} )}
{permissions?.service.create && (
<TransferService
id={composeId}
type="compose"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={composeId} type="compose" /> <DeleteService id={composeId} type="compose" />
)} )}
@@ -443,7 +434,6 @@ const Service = (
<ShowImport composeId={composeId} /> <ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} /> <AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} /> <IsolatedDeploymentTab composeId={composeId} />
<FreshVolumes composeId={composeId} />
</div> </div>
</TabsContent> </TabsContent>
)} )}
@@ -23,7 +23,6 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { LibsqlIcon } from "@/components/icons/data-tools-icons"; import { LibsqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -146,13 +145,6 @@ const Libsql = (
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateLibsql libsqlId={libsqlId} /> <UpdateLibsql libsqlId={libsqlId} />
{(auth?.role === "owner" || auth?.canCreateServices) && (
<TransferService
id={libsqlId}
type="libsql"
serverId={data?.serverId}
/>
)}
{(auth?.role === "owner" || auth?.canDeleteServices) && ( {(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={libsqlId} type="libsql" /> <DeleteService id={libsqlId} type="libsql" />
)} )}
@@ -23,7 +23,6 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MariadbIcon } from "@/components/icons/data-tools-icons"; import { MariadbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const Mariadb = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} /> <UpdateMariadb mariadbId={mariadbId} />
)} )}
{permissions?.service.create && (
<TransferService
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" /> <DeleteService id={mariadbId} type="mariadb" />
)} )}
@@ -23,7 +23,6 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MongodbIcon } from "@/components/icons/data-tools-icons"; import { MongodbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const Mongo = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdateMongo mongoId={mongoId} /> <UpdateMongo mongoId={mongoId} />
)} )}
{permissions?.service.create && (
<TransferService
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={mongoId} type="mongo" /> <DeleteService id={mongoId} type="mongo" />
)} )}
@@ -23,7 +23,6 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials"; import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql"; import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MysqlIcon } from "@/components/icons/data-tools-icons"; import { MysqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const MySql = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdateMysql mysqlId={mysqlId} /> <UpdateMysql mysqlId={mysqlId} />
)} )}
{permissions?.service.create && (
<TransferService
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={mysqlId} type="mysql" /> <DeleteService id={mysqlId} type="mysql" />
)} )}
@@ -23,7 +23,6 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials"; import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres"; import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { PostgresqlIcon } from "@/components/icons/data-tools-icons"; import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,13 +158,6 @@ const Postgresql = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdatePostgres postgresId={postgresId} /> <UpdatePostgres postgresId={postgresId} />
)} )}
{permissions?.service.create && (
<TransferService
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={postgresId} type="postgres" /> <DeleteService id={postgresId} type="postgres" />
)} )}
@@ -22,7 +22,6 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials"; import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
import { UpdateRedis } from "@/components/dashboard/redis/update-redis"; import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { RedisIcon } from "@/components/icons/data-tools-icons"; import { RedisIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,13 +158,6 @@ const Redis = (
{permissions?.service.create && ( {permissions?.service.create && (
<UpdateRedis redisId={redisId} /> <UpdateRedis redisId={redisId} />
)} )}
{permissions?.service.create && (
<TransferService
id={redisId}
type="redis"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && ( {permissions?.service.delete && (
<DeleteService id={redisId} type="redis" /> <DeleteService id={redisId} type="redis" />
)} )}
+6 -4
View File
@@ -12,10 +12,12 @@ function SchedulesPage() {
{(serverId) => ( {(serverId) => (
<div className="w-full"> <div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]"> <Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<ShowSchedules <div className="rounded-xl bg-background shadow-md h-full">
scheduleType={serverId ? "server" : "dokploy-server"} <ShowSchedules
id={serverId ?? "dokploy-server"} scheduleType={serverId ? "server" : "dokploy-server"}
/> id={serverId ?? "dokploy-server"}
/>
</div>
</Card> </Card>
</div> </div>
)} )}
@@ -5,7 +5,6 @@ import type { ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys"; import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account"; import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form"; import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
@@ -21,7 +20,6 @@ const Page = () => {
<ProfileForm /> <ProfileForm />
{isCloud && <LinkingAccount />} {isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />} {permissions?.api.read && <ShowApiKeys />}
<DeleteAccount />
</div> </div>
</div> </div>
); );
-1
View File
@@ -498,7 +498,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// Prefetch the public branding so the login/onboarding logo and app name // Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding). // render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch(); await helpers.whitelabeling.getPublic.prefetch();
await helpers.sso.showSignInWithSSO.prefetch();
if (IS_CLOUD) { if (IS_CLOUD) {
try { try {
-2
View File
@@ -53,7 +53,6 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe"; import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm"; import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag"; import { tagRouter } from "./routers/tag";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider"; import { vaultProviderRouter } from "./routers/vault-provider";
import { volumeBackupsRouter } from "./routers/volume-backups"; import { volumeBackupsRouter } from "./routers/volume-backups";
@@ -121,7 +120,6 @@ export const appRouter = createTRPCRouter({
tag: tagRouter, tag: tagRouter,
patch: patchRouter, patch: patchRouter,
overview: overviewRouter, overview: overviewRouter,
transfer: transferRouter,
}); });
// export type definition of API // export type definition of API
@@ -545,6 +545,7 @@ export const applicationRouter = createTRPCRouter({
sourceType: "github", sourceType: "github",
owner: input.owner, owner: input.owner,
buildPath: input.buildPath, buildPath: input.buildPath,
applicationStatus: "idle",
githubId: input.githubId, githubId: input.githubId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
triggerType: input.triggerType, triggerType: input.triggerType,
@@ -571,6 +572,7 @@ export const applicationRouter = createTRPCRouter({
gitlabBranch: input.gitlabBranch, gitlabBranch: input.gitlabBranch,
gitlabBuildPath: input.gitlabBuildPath, gitlabBuildPath: input.gitlabBuildPath,
sourceType: "gitlab", sourceType: "gitlab",
applicationStatus: "idle",
gitlabId: input.gitlabId, gitlabId: input.gitlabId,
gitlabProjectId: input.gitlabProjectId, gitlabProjectId: input.gitlabProjectId,
gitlabPathNamespace: input.gitlabPathNamespace, gitlabPathNamespace: input.gitlabPathNamespace,
@@ -599,6 +601,7 @@ export const applicationRouter = createTRPCRouter({
bitbucketBranch: input.bitbucketBranch, bitbucketBranch: input.bitbucketBranch,
bitbucketBuildPath: input.bitbucketBuildPath, bitbucketBuildPath: input.bitbucketBuildPath,
sourceType: "bitbucket", sourceType: "bitbucket",
applicationStatus: "idle",
bitbucketId: input.bitbucketId, bitbucketId: input.bitbucketId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules, enableSubmodules: input.enableSubmodules,
@@ -624,6 +627,7 @@ export const applicationRouter = createTRPCRouter({
giteaBranch: input.giteaBranch, giteaBranch: input.giteaBranch,
giteaBuildPath: input.giteaBuildPath, giteaBuildPath: input.giteaBuildPath,
sourceType: "gitea", sourceType: "gitea",
applicationStatus: "idle",
giteaId: input.giteaId, giteaId: input.giteaId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules, enableSubmodules: input.enableSubmodules,
@@ -648,6 +652,7 @@ export const applicationRouter = createTRPCRouter({
username: input.username, username: input.username,
password: input.password, password: input.password,
sourceType: "docker", sourceType: "docker",
applicationStatus: "idle",
registryUrl: input.registryUrl, registryUrl: input.registryUrl,
}); });
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
@@ -671,6 +676,7 @@ export const applicationRouter = createTRPCRouter({
customGitUrl: input.customGitUrl, customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId, customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git", sourceType: "git",
applicationStatus: "idle",
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules, enableSubmodules: input.enableSubmodules,
}); });
@@ -723,6 +729,7 @@ export const applicationRouter = createTRPCRouter({
customGitSSHKeyId: null, customGitSSHKeyId: null,
sourceType: "github", // Reset to default sourceType: "github", // Reset to default
applicationStatus: "idle",
watchPaths: null, watchPaths: null,
enableSubmodules: false, enableSubmodules: false,
}); });
@@ -759,6 +759,7 @@ export const composeRouter = createTRPCRouter({
customGitSSHKeyId: null, customGitSSHKeyId: null,
sourceType: "github", // Reset to default sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null, watchPaths: null,
enableSubmodules: false, enableSubmodules: false,
}); });
+2 -10
View File
@@ -19,10 +19,7 @@ import {
updateServerById, updateServerById,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import { findMemberByUserId } from "@dokploy/server/services/permission";
checkPermission,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
@@ -219,15 +216,10 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0; return servers.length ?? 0;
}), }),
withSSHKey: protectedProcedure.query(async ({ ctx }) => { withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
await checkPermission(
ctx,
IS_CLOUD ? { service: ["create"] } : { server: ["read"] },
);
const accessibleIds = await getAccessibleServerIds(ctx.session); const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({ const result = await db.query.server.findMany({
columns: { serverId: true, name: true, ipAddress: true },
orderBy: desc(server.createdAt), orderBy: desc(server.createdAt),
where: IS_CLOUD where: IS_CLOUD
? and( ? and(
@@ -388,9 +388,6 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, { await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems, items: updateItems,
proration_behavior: isTrialing ? "none" : "create_prorations", proration_behavior: isTrialing ? "none" : "create_prorations",
...(isTrialing && subscription.trial_end
? { trial_end: subscription.trial_end }
: {}),
}); });
return { ok: true }; return { ok: true };
@@ -1,87 +0,0 @@
import {
getAccessibleServerIds,
IS_CLOUD,
transferService,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { apiTransferService } from "@/server/db/schema";
export const transferRouter = createTRPCRouter({
start: protectedProcedure
.meta({
openapi: {
path: "/transfer/start",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiTransferService)
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.serviceId, {
service: ["create"],
deployment: ["create"],
});
if (input.targetServerId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.targetServerId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to the target server",
});
}
} else if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The Dokploy server is not available as a target",
});
}
const queue: string[] = [];
let done = false;
const log = (line: string) => queue.push(line);
const run = async () => {
const result = await transferService(
{
...input,
organizationId: ctx.session.activeOrganizationId,
},
log,
);
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: input.serviceId,
resourceName: result.appName,
metadata: { targetServerId: input.targetServerId },
});
};
run()
.then(() => log("Transfer completed successfully!"))
.catch((error) => {
log(
`Transfer failed ❌ ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
});
+10 -140
View File
@@ -1,10 +1,6 @@
import { import {
ACCOUNT_DELETION_CODE_TTL_MINUTES,
consumeAccountDeletionCode,
createAccountDeletionCode,
createApiKey, createApiKey,
createOrganizationUserWithCredentials, createOrganizationUserWithCredentials,
findCredentialAccount,
findNotificationById, findNotificationById,
findOrganizationById, findOrganizationById,
findPasskeysByUserId, findPasskeysByUserId,
@@ -15,7 +11,6 @@ import {
IS_CLOUD, IS_CLOUD,
removeUserById, removeUserById,
renderInvitationEmail, renderInvitationEmail,
sendAccountDeletionCodeEmail,
sendEmailNotification, sendEmailNotification,
sendResendNotification, sendResendNotification,
updateUser, updateUser,
@@ -29,7 +24,6 @@ import {
apiUpdateUser, apiUpdateUser,
invitation, invitation,
member, member,
organization,
session, session,
user, user,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
@@ -44,7 +38,6 @@ import { and, asc, desc, eq, gt, ne } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { apiKeyNameSchema } from "@/lib/api-keys"; import { apiKeyNameSchema } from "@/lib/api-keys";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { deleteAccount } from "@/server/utils/account-deletion";
import { import {
adminProcedure, adminProcedure,
createTRPCRouter, createTRPCRouter,
@@ -70,47 +63,6 @@ const apiCreateApiKey = z.object({
refillInterval: z.number().optional(), refillInterval: z.number().optional(),
}); });
const hasRootAccess = (ctx: {
user: { id: string };
session: { impersonatedBy?: string } | null;
}) => {
const rootId = process.env.USER_ADMIN_ID;
if (!IS_CLOUD || !rootId) {
return false;
}
return rootId === ctx.user.id || ctx.session?.impersonatedBy === rootId;
};
const assertCanDeleteOwnAccount = async (ctx: {
user: { id: string };
session: { impersonatedBy?: string };
}) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
if (ctx.session.impersonatedBy) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accounts cannot be deleted while impersonating",
});
}
const ownedOrganization = await db.query.organization.findFirst({
where: eq(organization.ownerId, ctx.user.id),
columns: { id: true },
});
if (!ownedOrganization) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners can delete their account",
});
}
};
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
all: withPermission("member", "read").query(async ({ ctx }) => { all: withPermission("member", "read").query(async ({ ctx }) => {
return await db.query.member.findMany({ return await db.query.member.findMany({
@@ -226,7 +178,16 @@ export const userRouter = createTRPCRouter({
return findPasskeysByUserId(ctx.user.id); return findPasskeysByUserId(ctx.user.id);
}), }),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => { haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
return hasRootAccess(ctx); if (!IS_CLOUD) {
return false;
}
if (
process.env.USER_ADMIN_ID === ctx.user.id ||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
) {
return true;
}
return false;
}), }),
getBackups: adminProcedure.query(async ({ ctx }) => { getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({ const memberResult = await db.query.member.findFirst({
@@ -497,97 +458,6 @@ export const userRouter = createTRPCRouter({
}); });
return result; return result;
}), }),
hasPassword: protectedProcedure.query(async ({ ctx }) => {
const credential = await findCredentialAccount(ctx.user.id);
return !!credential?.password;
}),
requestAccountDeletionCode: protectedProcedure
.input(z.object({ password: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
await assertCanDeleteOwnAccount(ctx);
const credential = await findCredentialAccount(ctx.user.id);
if (
credential?.password &&
!bcrypt.compareSync(input.password ?? "", credential.password)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The password is incorrect",
});
}
const code = await createAccountDeletionCode(ctx.user.id);
try {
await sendAccountDeletionCodeEmail({
email: ctx.user.email,
userName: ctx.user.name || ctx.user.email,
code,
expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES,
});
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "The confirmation email could not be sent",
});
}
return { expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES };
}),
deleteAccount: protectedProcedure
.input(z.object({ code: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertCanDeleteOwnAccount(ctx);
const { requestedAt } = await consumeAccountDeletionCode(
ctx.user.id,
input.code,
);
await audit(ctx, {
action: "delete",
resourceType: "user",
resourceId: ctx.user.id,
metadata: { selfService: true },
});
return deleteAccount(ctx.user.id, ctx.user.id, { requestedAt });
}),
deleteAccountByEmail: protectedProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
if (!hasRootAccess(ctx)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the root account can delete accounts by email",
});
}
const target = await db.query.user.findFirst({
where: eq(user.email, input.email.trim().toLowerCase()),
columns: { id: true },
});
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No account found with that email",
});
}
if (target.id === process.env.USER_ADMIN_ID) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The root account cannot be deleted",
});
}
return deleteAccount(
target.id,
ctx.session.impersonatedBy ?? ctx.user.id,
);
}),
assignPermissions: withPermission("member", "update") assignPermissions: withPermission("member", "update")
.input(apiAssignPermissions) .input(apiAssignPermissions)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -1,44 +0,0 @@
import {
deleteHubSpotContactByEmail,
deleteUserAccountData,
findUserById,
IS_CLOUD,
} from "@dokploy/server";
import { cancelStripeSubscriptions } from "./billing";
export const deleteAccount = async (
userId: string,
requestedBy: string,
options?: { requestedAt?: Date | null },
) => {
const target = await findUserById(userId);
const startedAt = new Date().toISOString();
const stripe =
IS_CLOUD && target.stripeCustomerId
? {
customerId: target.stripeCustomerId,
...(await cancelStripeSubscriptions(target.stripeCustomerId)),
}
: null;
const { organizationsDeleted } = await deleteUserAccountData(userId);
const hubspot = IS_CLOUD
? await deleteHubSpotContactByEmail(target.email)
: "skipped";
const record = {
userId,
email: target.email,
requestedBy,
requestedAt: options?.requestedAt?.toISOString() ?? startedAt,
startedAt,
completedAt: new Date().toISOString(),
organizationsDeleted,
stripe,
hubspot,
};
return record;
};
-40
View File
@@ -92,7 +92,6 @@ export interface BillingStatus {
hasUsedTrial: boolean; hasUsedTrial: boolean;
hasActiveAccess: boolean; hasActiveAccess: boolean;
hasPaymentMethod: boolean; hasPaymentMethod: boolean;
isAnnual: boolean;
} }
export const getBillingStatus = async ( export const getBillingStatus = async (
@@ -107,7 +106,6 @@ export const getBillingStatus = async (
hasUsedTrial: false, hasUsedTrial: false,
hasActiveAccess: true, hasActiveAccess: true,
hasPaymentMethod: true, hasPaymentMethod: true,
isAnnual: false,
}; };
} }
@@ -121,7 +119,6 @@ export const getBillingStatus = async (
hasUsedTrial: false, hasUsedTrial: false,
hasActiveAccess: false, hasActiveAccess: false,
hasPaymentMethod: false, hasPaymentMethod: false,
isAnnual: false,
}; };
} }
@@ -149,9 +146,6 @@ export const getBillingStatus = async (
const hasPaymentMethod = const hasPaymentMethod =
!!currentSub?.default_payment_method || !!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method; !!customer?.invoice_settings?.default_payment_method;
const isAnnual =
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
?.interval === "year";
const trialEndsAt = trialingSub?.trial_end const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000) ? new Date(trialingSub.trial_end * 1000)
: null; : null;
@@ -170,39 +164,5 @@ export const getBillingStatus = async (
hasPaymentMethod, hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0, hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub, hasActiveAccess: plan !== null || !!trialingSub,
isAnnual,
}; };
}; };
const isMissingResource = (error: unknown) =>
error instanceof Stripe.errors.StripeError &&
error.code === "resource_missing";
export const cancelStripeSubscriptions = async (
stripeCustomerId: string,
stripe: Stripe = getStripeClient(),
) => {
const subscriptions = await stripe.subscriptions
.list({ customer: stripeCustomerId, status: "all", limit: 100 })
.catch((error: unknown) => {
if (isMissingResource(error)) return null;
throw error;
});
const cancelledSubscriptions: string[] = [];
for (const subscription of subscriptions?.data ?? []) {
if (
subscription.status === "canceled" ||
subscription.status === "incomplete_expired"
) {
continue;
}
await stripe.subscriptions.cancel(subscription.id, {
invoice_now: false,
prorate: false,
});
cancelledSubscriptions.push(subscription.id);
}
return { cancelledSubscriptions };
};
+21 -30
View File
@@ -33,11 +33,6 @@ export const setupDeploymentLogsWebSocketServer = (
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
// Client may have disconnected during the await; a later close handler would never fire.
if (ws.readyState !== ws.OPEN) {
return;
}
// Generate unique connection ID for tracking // Generate unique connection ID for tracking
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`; const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
if (!logPath) { if (!logPath) {
@@ -59,33 +54,10 @@ export const setupDeploymentLogsWebSocketServer = (
let tailProcess: ReturnType<typeof spawn> | null = null; let tailProcess: ReturnType<typeof spawn> | null = null;
let sshClient: Client | null = null; let sshClient: Client | null = null;
// `killed` is set once a signal is sent, not when the process exits.
const isTailRunning = () =>
tailProcess !== null &&
tailProcess.exitCode === null &&
tailProcess.signalCode === null;
const stopTailProcess = () => {
if (!isTailRunning()) {
return;
}
tailProcess!.kill("SIGTERM");
// Force kill after a timeout if it doesn't terminate
setTimeout(() => {
if (isTailRunning()) {
tailProcess!.kill("SIGKILL");
}
}, 1000);
};
try { try {
if (serverId) { if (serverId) {
const server = await findServerById(serverId); const server = await findServerById(serverId);
if (ws.readyState !== ws.OPEN) {
return;
}
if (server.organizationId !== session.activeOrganizationId) { if (server.organizationId !== session.activeOrganizationId) {
ws.close(); ws.close();
return; return;
@@ -183,11 +155,30 @@ export const setupDeploymentLogsWebSocketServer = (
} }
}); });
ws.on("close", stopTailProcess); ws.on("close", () => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
// Force kill after a timeout if it doesn't terminate
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
} else {
}
}, 1000);
} else {
}
});
} }
} catch (error) { } catch (error) {
// Clean up resources on error // Clean up resources on error
stopTailProcess(); if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
}
}, 1000);
}
if (sshClient) { if (sshClient) {
sshClient.end(); sshClient.end();
} }
+2 -2
View File
@@ -3,10 +3,9 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20 go 1.20
require ( require (
github.com/gofiber/fiber/v2 v2.52.11 github.com/gofiber/fiber/v2 v2.52.6
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
) )
@@ -21,6 +20,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect github.com/tklauser/numcpus v0.8.0 // indirect
+4 -2
View File
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -49,6 +49,8 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+1 -3
View File
@@ -48,9 +48,7 @@
"overrides": { "overrides": {
"esbuild": "0.20.2", "esbuild": "0.20.2",
"better-call": "1.3.7", "better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1", "@better-fetch/fetch": "1.3.1"
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
}, },
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
+1 -1
View File
@@ -82,7 +82,7 @@
"react-dom": "19.2.7", "react-dom": "19.2.7",
"resend": "^6.0.2", "resend": "^6.0.2",
"semver": "7.7.3", "semver": "7.7.3",
"shell-quote": "^1.8.4", "shell-quote": "^1.8.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"ssh2": "~1.16.0", "ssh2": "~1.16.0",
"toml": "3.0.0", "toml": "3.0.0",
-1
View File
@@ -97,7 +97,6 @@ export const compose = pgTable("compose", {
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume") isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
.notNull() .notNull()
.default(false), .default(false),
pullImages: boolean("pullImages").notNull().default(false),
triggerType: triggerType("triggerType").default("push"), triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"), composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"), icon: text("icon"),
-1
View File
@@ -41,7 +41,6 @@ export * from "./shared";
export * from "./ssh-key"; export * from "./ssh-key";
export * from "./sso"; export * from "./sso";
export * from "./tag"; export * from "./tag";
export * from "./transfer";
export * from "./user"; export * from "./user";
export * from "./utils"; export * from "./utils";
export * from "./vault-provider"; export * from "./vault-provider";
+1 -3
View File
@@ -35,8 +35,6 @@ import {
UpdateConfigSwarmSchema, UpdateConfigSwarmSchema,
} from "./shared"; } from "./shared";
import { import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX, DATABASE_PASSWORD_REGEX,
encryptedText, encryptedText,
@@ -117,7 +115,7 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
const createSchema = createInsertSchema(libsql, { const createSchema = createInsertSchema(libsql, {
libsqlId: z.string(), libsqlId: z.string(),
name: z.string().min(1), name: z.string().min(1),
appName: z.string().min(1).max(63).regex(APP_NAME_REGEX, APP_NAME_MESSAGE), appName: z.string().min(1),
createdAt: z.string(), createdAt: z.string(),
databaseUser: z.string().min(1), databaseUser: z.string().min(1),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, { databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
@@ -1,9 +0,0 @@
import { z } from "zod";
import { serviceType } from "./mount";
export const apiTransferService = z.object({
serviceType: z.enum(serviceType.enumValues),
serviceId: z.string().min(1),
targetServerId: z.string().min(1).nullable(),
removeSourceData: z.boolean().default(false),
});
@@ -1,87 +0,0 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
userName: string;
code: string;
expiresInMinutes: number;
};
export const AccountDeletionCodeTemplate = ({
userName = "User",
code = "123456",
expiresInMinutes = 10,
}: TemplateProps) => {
const previewText = `${code} is your account deletion code`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={emailTailwindConfig}>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Confirm your account deletion
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, we received a request to permanently delete
your Dokploy account. Enter the code below to confirm it.
</Text>
<Section className="text-center mb-[24px]">
<Text className="inline-block bg-[#f4f4f5] rounded-lg text-[#09090b] text-[32px] font-semibold tracking-[8px] px-[24px] py-[16px] m-0">
{code}
</Text>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
This code expires in {expiresInMinutes} minutes. Once confirmed,
your subscriptions are cancelled and every organization you own
is deleted. This cannot be undone.
</Text>
</Section>
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
If you did not request this, someone else may have access to
your account. Change your password and contact{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy support
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default AccountDeletionCodeTemplate;
+3939 -1259
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -7,7 +7,6 @@ export * from "./db/validations/index";
export * from "./lib/auth"; export * from "./lib/auth";
export * from "./lib/logger"; export * from "./lib/logger";
export * from "./monitoring/utils"; export * from "./monitoring/utils";
export * from "./services/account-deletion";
export * from "./services/admin"; export * from "./services/admin";
export * from "./services/application"; export * from "./services/application";
export * from "./services/backup"; export * from "./services/backup";
@@ -55,7 +54,6 @@ export * from "./services/server";
export * from "./services/server-health"; export * from "./services/server-health";
export * from "./services/settings"; export * from "./services/settings";
export * from "./services/ssh-key"; export * from "./services/ssh-key";
export * from "./services/transfer";
export * from "./services/user"; export * from "./services/user";
export * from "./services/vault-provider"; export * from "./services/vault-provider";
export * from "./services/volume-backups"; export * from "./services/volume-backups";
@@ -122,7 +120,6 @@ export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold"; export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils"; export * from "./utils/notifications/utils";
export * from "./utils/process/execAsync"; export * from "./utils/process/execAsync";
export * from "./utils/process/remoteStream";
export * from "./utils/process/spawnAsync"; export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket"; export * from "./utils/providers/bitbucket";
export * from "./utils/providers/docker"; export * from "./utils/providers/docker";
-6
View File
@@ -126,12 +126,6 @@ const createBetterAuth = () =>
...(await resolveTrustedOrigins()), ...(await resolveTrustedOrigins()),
].filter(Boolean); ].filter(Boolean);
if (ctx.path === "/admin/remove-user") {
throw new APIError("FORBIDDEN", {
message: "Use the account deletion flow to remove users",
});
}
const isBlockedAuthPath = const isBlockedAuthPath =
ctx.path.startsWith("/sign-in/email") || ctx.path.startsWith("/sign-in/email") ||
ctx.path.startsWith("/sign-in/social") || ctx.path.startsWith("/sign-in/social") ||
+7 -15
View File
@@ -59,19 +59,6 @@ export const recordAdvancedStats = async (
} }
}; };
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const NON_PHYSICAL_DEVICE_PATTERNS = [
/^(loop|ram|zram)/,
/^(sr|fd)\d+$/,
// Partitions and md/dm devices repeat I/O already counted on the underlying disk.
/^(sd|hd|vd|xvd)[a-z]+\d+$/,
/^(nvme\d+n\d+|mmcblk\d+)p\d+$/,
/^(md|dm-)/,
];
export const isPhysicalDisk = (device: string) =>
!NON_PHYSICAL_DEVICE_PATTERNS.some((pattern) => pattern.test(device));
/** /**
* Get host system statistics using node-os-utils * Get host system statistics using node-os-utils
* This is used when monitoring "dokploy" to show host stats instead of container stats * This is used when monitoring "dokploy" to show host stats instead of container stats
@@ -112,9 +99,14 @@ export const getHostSystemStats = async (): Promise<Container> => {
let blockWriteBytes = 0; let blockWriteBytes = 0;
const diskStats = await osutils.disk.stats(); const diskStats = await osutils.disk.stats();
if (diskStats.success && diskStats.data.length > 0) { if (diskStats.success && diskStats.data.length > 0) {
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const excludePatterns = [/^loop/, /^ram/, /^sr\d+$/, /^fd\d+$/];
for (const stat of diskStats.data) { for (const stat of diskStats.data) {
// Skip virtual, partition and stacked devices // Skip virtual devices
if (stat.device && !isPhysicalDisk(stat.device)) { if (
stat.device &&
excludePatterns.some((pattern) => pattern.test(stat.device))
) {
continue; continue;
} }
// readBytes and writeBytes are DataSize objects with .toBytes() method // readBytes and writeBytes are DataSize objects with .toBytes() method
@@ -1,179 +0,0 @@
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { db } from "@dokploy/server/db";
import {
auditLog,
backups,
invitation,
organization,
user,
verification,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq, inArray } from "drizzle-orm";
import { nanoid } from "nanoid";
export const ANONYMIZED_EMAIL = "deleted-user";
export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10;
const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60;
const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5;
const codeIdentifier = (userId: string) => `account-deletion:${userId}`;
const hashCode = (code: string) =>
createHash("sha256").update(code.trim()).digest("hex");
export const generateDeletionCode = () =>
randomInt(0, 1_000_000).toString().padStart(6, "0");
export const serializeDeletionCode = (code: string) =>
JSON.stringify({ hash: hashCode(code), attempts: 0 });
export const checkDeletionCode = (value: string, code: string) => {
const stored = JSON.parse(value) as { hash: string; attempts: number };
const expected = Buffer.from(stored.hash, "hex");
const actual = Buffer.from(hashCode(code), "hex");
if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
return { valid: true as const, attempts: stored.attempts };
}
const attempts = stored.attempts + 1;
return {
valid: false as const,
attempts,
nextValue: JSON.stringify({ ...stored, attempts }),
};
};
export const createAccountDeletionCode = async (userId: string) => {
const identifier = codeIdentifier(userId);
const existing = await db.query.verification.findFirst({
where: eq(verification.identifier, identifier),
});
if (
existing?.createdAt &&
Date.now() - existing.createdAt.getTime() <
ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000
) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`,
});
}
await db.delete(verification).where(eq(verification.identifier, identifier));
const code = generateDeletionCode();
const now = new Date();
await db.insert(verification).values({
id: nanoid(),
identifier,
value: serializeDeletionCode(code),
expiresAt: new Date(
now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000,
),
createdAt: now,
updatedAt: now,
});
return code;
};
export const consumeAccountDeletionCode = async (
userId: string,
code: string,
) => {
const row = await db.query.verification.findFirst({
where: eq(verification.identifier, codeIdentifier(userId)),
});
if (!row) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Request a confirmation code first",
});
}
if (row.expiresAt.getTime() < Date.now()) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code has expired, request a new one",
});
}
const result = checkDeletionCode(row.value, code);
if (!result.valid) {
if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Too many incorrect attempts, request a new code",
});
}
await db
.update(verification)
.set({ value: result.nextValue, updatedAt: new Date() })
.where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code is incorrect",
});
}
await db.delete(verification).where(eq(verification.id, row.id));
return { requestedAt: row.createdAt };
};
export const deleteUserAccountData = async (userId: string) => {
return db.transaction(async (tx) => {
const target = await tx.query.user.findFirst({
where: eq(user.id, userId),
columns: { id: true, email: true },
});
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const ownedOrganizations = await tx.query.organization.findMany({
where: eq(organization.ownerId, userId),
columns: { id: true },
});
const organizationIds = ownedOrganizations.map((org) => org.id);
if (organizationIds.length > 0) {
await tx
.delete(auditLog)
.where(inArray(auditLog.organizationId, organizationIds));
}
// Actions performed in organizations owned by someone else stay in
// their audit trail, but without the deleted user's PII.
await tx
.update(auditLog)
.set({ userEmail: ANONYMIZED_EMAIL })
.where(eq(auditLog.userId, userId));
await tx.delete(invitation).where(eq(invitation.email, target.email));
// backups.userId has no ON DELETE clause, so a leftover reference would
// abort the user delete.
await tx
.update(backups)
.set({ userId: null })
.where(eq(backups.userId, userId));
await tx.delete(user).where(eq(user.id, userId));
return {
email: target.email,
organizationsDeleted: organizationIds.length,
};
});
};
-601
View File
@@ -1,601 +0,0 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiTransferService,
deployments,
network,
type ServiceType,
} from "@dokploy/server/db/schema";
import { removeService } from "@dokploy/server/utils/docker/utils";
import {
removeDirectoryCode,
removeMonitoringDirectory,
} from "@dokploy/server/utils/filesystem/directory";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
import {
readConfig,
readRemoteConfig,
removeTraefikConfig,
writeConfig,
writeConfigRemote,
} from "@dokploy/server/utils/traefik/application";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { removeForwardAuthMiddleware } from "@dokploy/server/utils/traefik/forward-auth";
import {
deleteAllMiddlewares,
removePathMiddlewares,
} from "@dokploy/server/utils/traefik/middleware";
import { createRedirectMiddleware } from "@dokploy/server/utils/traefik/redirect";
import { createSecurityMiddleware } from "@dokploy/server/utils/traefik/security";
import { TRPCError } from "@trpc/server";
import { eq, inArray, sql } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import {
deployApplication,
findApplicationById,
updateApplication,
} from "./application";
import {
deployCompose,
findComposeById,
removeCompose,
startCompose,
stopCompose,
updateCompose,
} from "./compose";
import { deployLibsql, findLibsqlById, updateLibsqlById } from "./libsql";
import { deployMariadb, findMariadbById, updateMariadbById } from "./mariadb";
import { deployMongo, findMongoById, updateMongoById } from "./mongo";
import { createFileMount } from "./mount";
import { deployMySql, findMySqlById, updateMySqlById } from "./mysql";
import {
deployPostgres,
findPostgresById,
updatePostgresById,
} from "./postgres";
import { deployRedis, findRedisById, updateRedisById } from "./redis";
import { findServerById } from "./server";
export type TransferServiceInput = z.infer<typeof apiTransferService> & {
organizationId: string;
};
type Logger = (line: string) => void;
type ServiceStatus = "idle" | "running" | "done" | "error";
const findService = async (serviceType: ServiceType, serviceId: string) => {
switch (serviceType) {
case "application":
return await findApplicationById(serviceId);
case "compose":
return await findComposeById(serviceId);
case "postgres":
return await findPostgresById(serviceId);
case "mysql":
return await findMySqlById(serviceId);
case "mariadb":
return await findMariadbById(serviceId);
case "mongo":
return await findMongoById(serviceId);
case "redis":
return await findRedisById(serviceId);
case "libsql":
return await findLibsqlById(serviceId);
}
};
type TransferableService = Awaited<ReturnType<typeof findService>>;
type ApplicationService = Awaited<ReturnType<typeof findApplicationById>>;
type ComposeService = Awaited<ReturnType<typeof findComposeById>>;
const isApplication = (
service: TransferableService,
): service is ApplicationService => "applicationId" in service;
const isCompose = (service: TransferableService): service is ComposeService =>
"composeId" in service;
const updateServer = async (
serviceType: ServiceType,
serviceId: string,
data: { serverId: string | null; networkIds?: string[] | null },
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, data);
case "compose":
return await updateCompose(serviceId, { serverId: data.serverId });
case "postgres":
return await updatePostgresById(serviceId, data);
case "mysql":
return await updateMySqlById(serviceId, data);
case "mariadb":
return await updateMariadbById(serviceId, data);
case "mongo":
return await updateMongoById(serviceId, data);
case "redis":
return await updateRedisById(serviceId, data);
case "libsql":
return await updateLibsqlById(serviceId, data);
}
};
const updateStatus = async (
serviceType: ServiceType,
serviceId: string,
status: ServiceStatus,
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, { applicationStatus: status });
case "compose":
return await updateCompose(serviceId, { composeStatus: status });
case "postgres":
return await updatePostgresById(serviceId, {
applicationStatus: status,
});
case "mysql":
return await updateMySqlById(serviceId, { applicationStatus: status });
case "mariadb":
return await updateMariadbById(serviceId, { applicationStatus: status });
case "mongo":
return await updateMongoById(serviceId, { applicationStatus: status });
case "redis":
return await updateRedisById(serviceId, { applicationStatus: status });
case "libsql":
return await updateLibsqlById(serviceId, { applicationStatus: status });
}
};
const q = (value: string) => quote([value]);
const runOn = (serverId: string | null, command: string) =>
serverId ? execAsyncRemote(serverId, command) : execAsync(command);
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const directoryExists = async (serverId: string | null, directory: string) => {
const { stdout } = await runOn(
serverId,
`[ -d ${q(directory)} ] && echo yes || echo no`,
);
return stdout.trim() === "yes";
};
const formatBytes = (bytes: number) => {
const mib = bytes / (1024 * 1024);
if (mib < 1) return `${(bytes / 1024).toFixed(1)} KiB`;
if (mib < 1024) return `${mib.toFixed(1)} MiB`;
return `${(mib / 1024).toFixed(2)} GiB`;
};
export const volumeExportCommand = (volume: string) =>
`docker run --rm -v ${q(volume)}:/data:ro alpine tar -czf - --numeric-owner -C /data .`;
export const volumeImportCommand = (volume: string) =>
`docker volume create ${q(volume)} >/dev/null && docker run --rm -i -v ${q(volume)}:/data alpine tar -xzf - --numeric-owner -C /data`;
export const directoryExportCommand = (directory: string) =>
`COPYFILE_DISABLE=1 tar -czf - --numeric-owner -C ${q(directory)} .`;
export const directoryImportCommand = (directory: string) =>
`mkdir -p ${q(directory)} && tar -xzf - --numeric-owner -C ${q(directory)}`;
const PROGRESS_STEP = 64 * 1024 * 1024;
const copyStream = async (
label: string,
source: { serverId: string | null; command: string },
target: { serverId: string | null; command: string },
log: Logger,
) => {
let reported = 0;
const bytes = await pipeBetweenServers({
source,
target,
onProgress: (transferred) => {
if (transferred - reported >= PROGRESS_STEP) {
reported = transferred;
log(` ${label}: ${formatBytes(transferred)} transferred`);
}
},
});
log(` ${label}: done (${formatBytes(bytes)})`);
};
const listVolumes = async (service: TransferableService) => {
const names = new Set<string>();
for (const mount of service.mounts) {
if (mount.type === "volume" && mount.volumeName) {
names.add(mount.volumeName);
}
}
if (isCompose(service)) {
const project = q(service.appName);
const { stdout } = await runOn(
service.serverId ?? null,
`docker volume ls -q --filter label=com.docker.compose.project=${project}; docker volume ls -q --filter label=com.docker.stack.namespace=${project}`,
);
for (const line of stdout.split("\n")) {
const name = line.trim();
if (name) names.add(name);
}
}
return [...names];
};
const listBindMounts = (service: TransferableService) => [
...new Set(
service.mounts
.filter((mount) => mount.type === "bind" && mount.hostPath)
.map((mount) => mount.hostPath as string),
),
];
const stopOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
try {
await stopCompose(service.composeId);
} catch (error) {
log(` Could not stop compose: ${errorMessage(error)}`);
}
return;
}
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=0 >/dev/null 2>&1 || true`,
);
};
const startOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
if (service.composeType === "stack") {
log(
" Stack was removed on the source server, run Deploy to bring it back",
);
return;
}
try {
await startCompose(service.composeId);
} catch (error) {
log(` Could not start compose: ${errorMessage(error)}`);
}
return;
}
const replicas = isApplication(service) ? service.replicas : 1;
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=${replicas} >/dev/null 2>&1 || true`,
);
};
const resolveNetworkIds = async (
networkIds: string[] | null | undefined,
targetServerId: string | null,
) => {
if (!networkIds || networkIds.length === 0) {
return { kept: [] as string[], dropped: [] as string[] };
}
const rows = await db.query.network.findMany({
where: inArray(network.networkId, networkIds),
columns: { networkId: true, name: true, serverId: true },
});
return {
kept: rows
.filter((row) => (row.serverId ?? null) === targetServerId)
.map((row) => row.networkId),
dropped: rows
.filter((row) => (row.serverId ?? null) !== targetServerId)
.map((row) => row.name),
};
};
const moveTraefikConfig = async (
source: ApplicationService,
moved: ApplicationService,
log: Logger,
) => {
const config = source.serverId
? await readRemoteConfig(source.serverId, source.appName)
: readConfig(source.appName);
if (config) {
if (moved.serverId) {
await writeConfigRemote(moved.serverId, moved.appName, config);
} else {
writeConfig(moved.appName, config);
}
}
for (const domain of moved.domains) {
await manageDomain(moved, domain);
}
for (const security of moved.security) {
await createSecurityMiddleware(moved, security);
}
for (const redirect of moved.redirects) {
await createRedirectMiddleware(moved, redirect);
}
log(` Traefik configuration recreated (${moved.domains.length} domains)`);
};
const removeSourceTraefikConfig = async (source: ApplicationService) => {
for (const domain of source.domains) {
await removePathMiddlewares(source, domain.uniqueConfigKey);
await removeForwardAuthMiddleware(source, domain.uniqueConfigKey);
}
await deleteAllMiddlewares(source);
await removeTraefikConfig(source.appName, source.serverId);
};
const moveDeploymentLogs = async (
service: ApplicationService | ComposeService,
targetServerId: string | null,
log: Logger,
) => {
const sourceServerId = service.serverId ?? null;
const from = path.join(paths(!!sourceServerId).LOGS_PATH, service.appName);
const to = path.join(paths(!!targetServerId).LOGS_PATH, service.appName);
if (!(await directoryExists(sourceServerId, from))) return;
log("Copying deployment logs");
await copyStream(
"deployment logs",
{ serverId: sourceServerId, command: directoryExportCommand(from) },
{ serverId: targetServerId, command: directoryImportCommand(to) },
log,
);
if (from !== to) {
await db
.update(deployments)
.set({ logPath: sql`replace(${deployments.logPath}, ${from}, ${to})` })
.where(
isCompose(service)
? eq(deployments.composeId, service.composeId)
: eq(deployments.applicationId, service.applicationId),
);
}
await runOn(sourceServerId, `rm -rf ${q(from)}`);
};
const cleanupSource = async (
service: TransferableService,
volumes: string[],
removeSourceData: boolean,
log: Logger,
) => {
const serverId = service.serverId ?? null;
const steps: Array<() => Promise<unknown>> = [];
if (isApplication(service)) {
steps.push(
() => removeSourceTraefikConfig(service),
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
() => removeMonitoringDirectory(service.appName, serverId),
);
} else if (isCompose(service)) {
steps.push(() => removeCompose(service, removeSourceData));
} else {
steps.push(
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
);
}
if (removeSourceData && volumes.length > 0) {
steps.push(() =>
runOn(
serverId,
`docker volume rm ${volumes.map(q).join(" ")} >/dev/null 2>&1 || true`,
),
);
}
for (const step of steps) {
try {
await step();
} catch (error) {
log(` Warning: ${errorMessage(error)}`);
}
}
if (!removeSourceData && volumes.length > 0) {
log(` Volumes kept on the source server: ${volumes.join(", ")}`);
}
};
const deployOnTarget = async (
serviceType: ServiceType,
serviceId: string,
targetName: string,
log: Logger,
) => {
log(`Deploying on ${targetName}`);
const deployment = {
titleLog: "Deploy after transfer",
descriptionLog: `Transferred to ${targetName}`,
};
switch (serviceType) {
case "application":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployApplication({
applicationId: serviceId,
...deployment,
});
case "compose":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployCompose({ composeId: serviceId, ...deployment });
case "postgres":
return await deployPostgres(serviceId, log);
case "mysql":
return await deployMySql(serviceId, log);
case "mariadb":
return await deployMariadb(serviceId, log);
case "mongo":
return await deployMongo(serviceId, log);
case "redis":
return await deployRedis(serviceId, log);
case "libsql":
return await deployLibsql(serviceId, log);
}
};
export const transferService = async (
input: TransferServiceInput,
log: Logger,
) => {
const { serviceType, serviceId, targetServerId, removeSourceData } = input;
const service = await findService(serviceType, serviceId);
if (service.environment.project.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to transfer this service",
});
}
const sourceServerId = service.serverId ?? null;
if (sourceServerId === targetServerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The service is already on the selected server",
});
}
const targetServer = targetServerId
? await findServerById(targetServerId)
: null;
if (targetServer) {
if (targetServer.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to use the target server",
});
}
if (
targetServer.serverStatus !== "active" ||
targetServer.serverType !== "deploy"
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The target server is not available for deployments",
});
}
}
const sourceName = service.server?.name ?? "Dokploy Server";
const targetName = targetServer?.name ?? "Dokploy Server";
const originalNetworkIds =
"networkIds" in service ? service.networkIds : undefined;
let switched = false;
log(`Transferring ${service.appName} from ${sourceName} to ${targetName}`);
try {
log(`Stopping service on ${sourceName}`);
await stopOnSource(service, log);
await updateStatus(serviceType, serviceId, "running");
const volumes = await listVolumes(service);
const bindMounts = listBindMounts(service);
log(
`Found ${volumes.length} volume(s) and ${bindMounts.length} bind mount(s)`,
);
for (const volume of volumes) {
log(`Copying volume ${volume}`);
await copyStream(
volume,
{ serverId: sourceServerId, command: volumeExportCommand(volume) },
{ serverId: targetServerId, command: volumeImportCommand(volume) },
log,
);
}
for (const hostPath of bindMounts) {
if (!(await directoryExists(sourceServerId, hostPath))) {
log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`);
continue;
}
log(`Copying bind mount ${hostPath}`);
await copyStream(
hostPath,
{
serverId: sourceServerId,
command: directoryExportCommand(hostPath),
},
{
serverId: targetServerId,
command: directoryImportCommand(hostPath),
},
log,
);
}
const networks = await resolveNetworkIds(
originalNetworkIds,
targetServerId,
);
if (networks.dropped.length > 0) {
log(
`Detaching networks not available on ${targetName}: ${networks.dropped.join(", ")}`,
);
}
await updateServer(serviceType, serviceId, {
serverId: targetServerId,
networkIds: originalNetworkIds === undefined ? undefined : networks.kept,
});
switched = true;
const moved = await findService(serviceType, serviceId);
const fileMounts = moved.mounts.filter((mount) => mount.type === "file");
if (fileMounts.length > 0) {
log(`Recreating ${fileMounts.length} file mount(s) on ${targetName}`);
for (const mount of fileMounts) {
await createFileMount(mount.mountId);
}
}
if (isApplication(service) && isApplication(moved)) {
log(`Recreating Traefik configuration on ${targetName}`);
await moveTraefikConfig(service, moved, log);
}
if (isApplication(service) || isCompose(service)) {
await moveDeploymentLogs(service, targetServerId, log);
}
log(`Cleaning up ${sourceName}`);
await cleanupSource(service, volumes, removeSourceData, log);
await updateStatus(serviceType, serviceId, "idle");
} catch (error) {
if (switched) {
await updateServer(serviceType, serviceId, {
serverId: sourceServerId,
networkIds: originalNetworkIds,
});
}
log(`Data already copied to ${targetName} was left in place`);
log(`Restoring service on ${sourceName}`);
await startOnSource(service, log);
await updateStatus(serviceType, serviceId, "error");
throw error;
}
try {
await deployOnTarget(serviceType, serviceId, targetName, log);
} catch (error) {
await updateStatus(serviceType, serviceId, "error");
log(
`Data was transferred but the deployment on ${targetName} failed, run Deploy to retry`,
);
throw error;
}
return {
serviceType,
serviceId,
appName: service.appName,
targetServerId,
targetName,
};
};
-10
View File
@@ -566,13 +566,3 @@ export const createApiKey = async (
return result; return result;
}; };
export const findCredentialAccount = async (userId: string) => {
return db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, "credential"),
),
columns: { id: true, password: true },
});
};

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