Compare commits

..

4 Commits

Author SHA1 Message Date
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
Mauricio Siu 16ac4633f3 Merge pull request #5477 from Dokploy/feat/trial-expiration-emails
fix(billing): pick a plan for the trial and warn before it expires

(cherry picked from commit d7d75f7c5c)

[skip ci]
2026-09-18 09:04:19 +00:00
98 changed files with 5306 additions and 13281 deletions
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from package.json
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "$LATEST_TAG"
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
run: |
if [ "$VERSION" != "$LATEST_TAG" ]; then
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary
gh pr create \
--title "🚀 Release $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to $VERSION
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
+19 -28
View File
@@ -5,38 +5,33 @@ on:
branches: [main, canary]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push-cloud-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.cloud
@@ -53,28 +48,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.schedule
@@ -87,28 +80,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.server
+42 -60
View File
@@ -8,23 +8,18 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -32,15 +27,15 @@ jobs:
- name: Set tag and version
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -48,7 +43,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
@@ -58,15 +53,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -75,15 +68,15 @@ jobs:
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
@@ -91,7 +84,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
@@ -103,22 +96,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
@@ -130,7 +121,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
@@ -147,37 +138,32 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
@@ -193,19 +179,15 @@ jobs:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
@@ -220,63 +202,63 @@ jobs:
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version $NPM_VERSION"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version $NPM_VERSION"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version $NPM_VERSION"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+2 -7
View File
@@ -6,20 +6,15 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v5
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
+2 -4
View File
@@ -16,19 +16,17 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="$MERGE_SHA"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
+1 -5
View File
@@ -7,16 +7,12 @@ concurrency:
group: hotfix-to-main
cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+19 -28
View File
@@ -7,23 +7,18 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -31,17 +26,17 @@ jobs:
- name: Set tag
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -52,15 +47,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,17 +61,17 @@ jobs:
- name: Set
id: meta
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -91,29 +84,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
+3 -5
View File
@@ -14,11 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"
+4 -7
View File
@@ -12,20 +12,17 @@ on:
workflow_dispatch:
permissions:
contents: read
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
@@ -28,9 +28,6 @@ env:
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
@@ -424,10 +421,8 @@ jobs:
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
STATUS="$JOB_STATUS"
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
@@ -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",
},
});
});
-71
View File
@@ -472,77 +472,6 @@ describe("infisical client", () => {
expect(params.get("secretPath")).toBe("/frontend");
});
it("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets the later import win when two define the same key", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/base",
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
},
{
secretPath: "/override",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://override" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
// Infisical documents this order: "If two imports carry a secret with the
// same name, the value from the bottom-most import wins." The response
// lists imports in that order, so assigning them in sequence matches it.
expect(result).toEqual({ DB_URL: "postgres://override" });
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
@@ -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");
});
});
@@ -6,28 +6,41 @@ import { api } from "@/utils/api";
export const TrialBanner = () => {
const router = useRouter();
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
const { mutateAsync: createCustomerPortalSession, isPending } =
api.stripe.createCustomerPortalSession.useMutation();
if (!billingStatus?.isOnTrial) {
const handleAddPaymentMethod = async () => {
const session = await createCustomerPortalSession().catch(() => null);
if (session?.url) {
window.open(session.url);
return;
}
router.push("/dashboard/settings/billing");
};
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
return null;
}
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
return (
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
<span className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-primary shrink-0" />
{daysRemaining > 0
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
: "Your free trial ends today."}
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
: "Your free trial ends today. Add a payment method to keep your servers."}
</span>
<Button
size="sm"
variant="default"
className="h-7"
onClick={() => router.push("/dashboard/settings/billing")}
isLoading={isPending}
onClick={handleAddPaymentMethod}
>
Upgrade now
Add payment method
</Button>
</div>
);
@@ -22,7 +22,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
@@ -31,7 +30,6 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data) {
if (data?.command) {
form.reset({
command: data.command || "",
pullImages: data.pullImages ?? false,
command: data?.command || "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (formData: AddCommand) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
composeId,
command: formData.command,
pullImages: formData.pullImages,
command: data?.command,
})
.then(async () => {
toast.success("Command Updated");
@@ -114,30 +109,6 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
{data?.composeType === "docker-compose" && (
<FormField
control={form.control}
name="pullImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="space-y-0.5">
<FormLabel>Pull latest images on deploy</FormLabel>
<FormDescription>
Adds <strong>--pull always</strong> to the default
command so every deploy fetches the newest image for
each tag. Has no effect when a custom command is set.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="command"
@@ -1,216 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const freshVolumesSchema = z.object({
confirmName: z.string().min(1, {
message: "Compose name is required",
}),
});
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
interface Props {
composeId: string;
}
export const FreshVolumes = ({ composeId }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
const form = useForm<FreshVolumesForm>({
defaultValues: { confirmName: "" },
resolver: zodResolver(freshVolumesSchema),
});
const expectedName = `${data?.name}/${data?.appName}`;
const isRunning = data?.composeStatus === "running";
const onSubmit = async (formData: FreshVolumesForm) => {
if (formData.confirmName !== expectedName) {
form.setError("confirmName", {
message: `Compose name must match "${expectedName}"`,
});
return;
}
await deploy({ composeId, freshVolumes: true })
.then(() => {
toast.success("Compose deployed with fresh volumes");
setIsOpen(false);
form.reset();
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
};
if (!canDeploy || data?.composeType !== "docker-compose") return null;
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">
Deploy with Fresh Volumes
</h3>
<p className="text-sm text-muted-foreground">
Permanently wipes every volume of this compose and redeploys it
from a clean state. All persistent data (databases, uploads,
caches) will be lost.
</p>
</div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) form.reset();
}}
>
<DialogTrigger asChild>
<Button
isLoading={isPending}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<HardDriveDownload className="mr-2 h-4 w-4" />
Deploy with Fresh Volumes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current compose</li>
<li>Delete all volumes and their data</li>
<li>Redeploy the compose with empty volumes</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-fresh-volumes"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="confirmName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(expectedName);
toast.success(
"Copied to clipboard. Be careful!",
);
}
}}
>
{expectedName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{isRunning && (
<AlertBlock type="warning" className="w-full">
Cannot deploy with fresh volumes while a deployment is
running. Please wait for it to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
isLoading={isPending}
disabled={isRunning}
form="hook-form-fresh-volumes"
type="submit"
variant="destructive"
>
Wipe volumes and deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
};
@@ -1,4 +1,11 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => {
<DialogTitle>Skip onboarding?</DialogTitle>
<DialogDescription>
If this is your first time using Dokploy, we recommend going
through these steps — it only takes a couple of minutes and gives
you a feel for how projects, servers and deployments fit together.
through these steps – it only takes a couple of minutes and gives
you a feel for how projects, servers, and deployments fit
together.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -15,13 +15,50 @@ const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
type Tier = "hobby" | "startup";
const PLANS: {
tier: Tier;
name: string;
description: string;
price: number;
features: string[];
recommended?: boolean;
}[] = [
{
tier: "hobby",
name: "Hobby",
description: "For individual developers",
price: calculatePriceHobby(1, false),
features: [
"Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
],
},
{
tier: "startup",
name: "Startup",
description: "For small to mid-size teams",
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
recommended: true,
features: [
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
],
},
];
interface Props {
onNext: () => void;
}
export const PlanStep = ({ onNext }: Props) => {
const [loadingTier, setLoadingTier] = useState<
"hobby" | "startup" | "trial" | null
const [loading, setLoading] = useState<
`${"trial" | "checkout"}:${Tier}` | null
>(null);
const { data } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
@@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => {
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const handleCheckout = async (tier: "hobby" | "startup") => {
const handleCheckout = async (tier: Tier) => {
if (!data) return;
const productId =
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
if (!productId) return;
setLoadingTier(tier);
setLoading(`checkout:${tier}`);
try {
const stripe = await stripePromise;
const session = await createCheckoutSession({
@@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => {
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
} catch {
toast.error("Error starting checkout");
setLoadingTier(null);
setLoading(null);
}
};
const handleTrial = async () => {
setLoadingTier("trial");
const handleTrial = async (tier: Tier) => {
setLoading(`trial:${tier}`);
try {
await startFreeTrial();
await startFreeTrial({ tier });
await utils.project.onboardingStatus.invalidate();
toast.success("Your 7-day trial has started");
onNext();
@@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => {
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setLoadingTier(null);
setLoading(null);
}
};
@@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => {
Start free, upgrade when ready.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
No credit card for the trial — add one only if you decide to stay.
Try any plan free for 7 days. No credit card required — add one only
if you decide to stay.
</p>
</div>
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
<div>
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
Recommended
</span>
<p className={`${displayFont.className} text-2xl mt-3`}>
7-day free trial
</p>
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
No card required — cancel anytime.
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"Setup 1 server",
"Unlimited apps & databases",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
>
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
isLoading={loadingTier === "trial"}
disabled={loadingTier !== null}
onClick={handleTrial}
>
Start trial
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Hobby</p>
<p className="text-sm text-muted-foreground mt-1">
For individual developers
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${calculatePriceHobby(1, false).toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
{PLANS.map((plan) => {
const productId =
plan.tier === "hobby"
? data?.hobbyProductId
: data?.startupProductId;
return (
<div
key={plan.tier}
className="flex flex-col justify-between gap-6 bg-background p-7"
>
<div>
{plan.recommended && (
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
Recommended
</span>
)}
<p className="font-medium">{plan.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{plan.description}
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${plan.price.toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{plan.features.map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<Button
isLoading={loading === `trial:${plan.tier}`}
disabled={loading !== null}
onClick={() => handleTrial(plan.tier)}
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "hobby"}
disabled={loadingTier !== null || !data?.hobbyProductId}
onClick={() => handleCheckout("hobby")}
>
Subscribe
</Button>
</div>
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Startup</p>
<p className="text-sm text-muted-foreground mt-1">
For small to mid-size teams
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
$
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
2,
)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
Start 7-day free trial
<ArrowRightIcon className="size-4" />
</Button>
<Button
variant="outline"
isLoading={loading === `checkout:${plan.tier}`}
disabled={loading !== null || !productId}
onClick={() => handleCheckout(plan.tier)}
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "startup"}
disabled={loadingTier !== null || !data?.startupProductId}
onClick={() => handleCheckout("startup")}
>
Subscribe
</Button>
</div>
Subscribe now
</Button>
</div>
</div>
);
})}
</div>
</div>
);
@@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {
</span>
<h1 className={titleClassName}>Create your first project.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Projects group your apps, databases and environments together.
Projects group your apps, databases, and environments together.
</p>
</div>
@@ -1,6 +1,12 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
import {
CheckCircle2,
CopyIcon,
InfoIcon,
Loader2,
XCircle,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
</span>
<h1 className={titleClassName}>Connect a server.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Dokploy deploys to servers you own — buy one from any VPS provider
Dokploy deploys to servers you own. Buy one from any VPS provider
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
</p>
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">Requirements</span>
<span className="text-sm text-muted-foreground">
To ensure a smooth experience with Dokploy, your server should
have at least 1GB of RAM and 30GB of disk space.
</span>
</div>
</div>
</div>
{!createdServerId ? (
@@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{isSettingUp ? (
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin shrink-0" />
Setting up your server — installing Docker and dependencies...
Setting up your server – installing Docker and dependencies…
</div>
) : isValidating && !validation ? (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
</div>
{!isReady && !isSettingUp && (
<AlertBlock type="info">
Setup usually takes a minute after the server boots — this checks
Setup runs after the server boots and this page checks for it
automatically. You can retry setup, or use "Skip for now" above to
continue and come back to this later.
</AlertBlock>
@@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => {
Let's get your first app <em className="not-italic">live</em>.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
A few steps — pick a plan, connect a server, ship something. You'll
have a working URL by the end.
It just takes a few steps: pick a plan, connect a server, ship
something. You'll have a working URL by the end.
</p>
</div>
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -24,7 +24,7 @@ export const AiForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -105,13 +105,47 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
const { mutateAsync: startFreeTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleStartTrial = async () => {
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
const planName = tier === "startup" ? "Startup" : "Hobby";
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: false,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
utils.server.count.invalidate(),
]);
};
setSwitchingTrial(true);
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on the ${planName} plan`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
.unwrap()
.catch(() => undefined);
setSwitchingTrial(false);
};
const handleStartTrial = async (tier: "hobby" | "startup") => {
setTrialTier(tier);
try {
await startFreeTrial();
await startFreeTrial({ tier });
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
@@ -122,6 +156,8 @@ export const ShowBilling = () => {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setTrialTier(null);
}
};
@@ -170,12 +206,23 @@ export const ShowBilling = () => {
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
const products = data?.products.filter((product) => {
// @ts-ignore
// @ts-expect-error
const interval = product?.default_price?.recurring?.interval;
return isAnnual ? interval === "year" : interval === "month";
});
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
const canStartTrial =
!isEnterpriseCloud &&
!!billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial;
/** Checkout would create a second subscription alongside a running trial. */
const canSubscribe =
!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 &&
!billingStatus?.isOnTrial;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -296,67 +343,72 @@ export const ShowBilling = () => {
billingStatus.trialEndsAt
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
: ""
}${
billingStatus.hasPaymentMethod
? " · billing starts automatically"
: " · add a card to keep your servers"
}`
: "You're subscribed and billed automatically."}
</span>
</div>
</div>
{billingStatus.isOnTrial &&
admin?.user.stripeCustomerId && (
{admin?.user.stripeCustomerId && (
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan && (
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
)}
<Button
size="sm"
variant="outline"
variant={
billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "default"
: "secondary"
}
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Add payment method
{billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "Add payment method"
: "Manage Subscription"}
</Button>
)}
</div>
)}
{!isEnterpriseCloud &&
billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
7-day free trial
</span>
<span className="text-sm text-muted-foreground">
No credit card required — cancel anytime.
</span>
<ul className="flex flex-col gap-1 mt-1">
{[
"Setup 1 server",
"Unlimited apps & databases",
"Community support",
].map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{feature}
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
isLoading={isStartingTrial}
onClick={handleStartTrial}
>
Start trial
</Button>
)}
</div>
)}
{canStartTrial && (
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">
7-day free trial on any plan
</span>
<span className="text-sm text-muted-foreground">
No credit card required — pick Hobby or Startup below.
</span>
</div>
</div>
)}
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -933,68 +985,71 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() => setHobbyServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
{canSubscribe && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) =>
Math.max(1, q - 1),
)
}
>
Manage Subscription
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
<Button
className="w-full"
isLoading={trialTier === "hobby"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("hobby")}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1067,85 +1122,83 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
disabled={
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
{canSubscribe && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity <
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
>
Get Started
<MinusIcon className="h-4 w-4" />
</Button>
)}
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
<Button
className="w-full"
isLoading={trialTier === "startup"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("startup")}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1337,31 +1390,17 @@ export const ShowBilling = () => {
</Button>
</div>
<div className="flex flex-col gap-2 mt-4 w-full">
{admin?.user.stripeCustomerId && (
{canSubscribe && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Manage Subscription
Subscribe
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>
</section>
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -49,7 +49,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl w-full">
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -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}
/>
</>
);
};
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import { useHubSpotChat } from "../shared/analytics";
import Page from "./side";
interface Props {
@@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
useHubSpotChat(isChatEnabled);
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
const shouldRedirectToOnboarding =
@@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -1,14 +0,0 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};
@@ -92,5 +92,11 @@ export const useHubSpotChat = (enabled: boolean) => {
loadWidget,
];
}
return () => {
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
(callback) => callback !== loadWidget,
);
window.HubSpotConversations?.widget.remove();
};
}, [enabled]);
};
@@ -30,7 +30,6 @@ const dockerComposeServices = [
{ label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" },
{ label: "models", type: "keyword", info: "Define AI models" },
].map((opt) => ({
...opt,
apply: (
@@ -84,11 +83,6 @@ const dockerComposeServiceOptions = [
},
{ label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" },
{
label: "models",
type: "keyword",
info: "AI models to use, referencing top-level models",
},
].map((opt) => ({
...opt,
apply: (
@@ -35,16 +35,6 @@
"additionalProperties": false
},
"models": {
"id": "#/properties/models",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/model"
}
}
},
"networks": {
"id": "#/properties/networks",
"type": "object",
@@ -345,30 +335,6 @@
"mem_swappiness": { "type": ["integer", "string"] },
"memswap_limit": { "type": ["number", "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": {
"oneOf": [
{ "$ref": "#/definitions/list_of_strings" },
@@ -924,23 +890,6 @@
"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": {
"oneOf": [
{ "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,
"tag": "0195_classy_whirlwind",
"breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
}
]
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.6",
"version": "v0.30.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -109,7 +109,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "16.3.4",
"next": "16.3.0",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -133,7 +133,7 @@
"resend": "^6.0.2",
"semver": "7.7.3",
"shadcn": "^4.11.0",
"shell-quote": "^1.8.4",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
@@ -180,7 +180,7 @@
"tsx": "^4.22.4",
"typescript": "^7.0.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^4.1.0"
"vitest": "^4.0.18"
},
"ct3aMetadata": {
"initVersion": "7.25.2"
+39 -16
View File
@@ -9,6 +9,7 @@ import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -72,6 +73,7 @@ export default async function handler(
"invoice.payment_failed",
"customer.deleted",
"checkout.session.completed",
"customer.subscription.trial_will_end",
];
if (!webhooksAllowed.includes(event.type)) {
@@ -110,6 +112,12 @@ export default async function handler(
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
break;
}
case "customer.subscription.trial_will_end": {
const trialingSubscription = event.data.object as Stripe.Subscription;
await sendTrialExpiringEmail(stripe, trialingSubscription, event.created);
break;
}
case "customer.subscription.created": {
const newSubscription = event.data.object as Stripe.Subscription;
@@ -176,7 +184,10 @@ export default async function handler(
break;
}
if (newSubscription.status === "active") {
if (
newSubscription.status === "active" ||
newSubscription.status === "trialing"
) {
const serversQuantity = getSubscriptionServersQuantity(
newSubscription?.items?.data ?? [],
);
@@ -209,9 +220,12 @@ export default async function handler(
case "invoice.payment_succeeded": {
const newInvoice = event.data.object as Stripe.Invoice;
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
if (!subscriptionId) {
break;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status !== "active") {
console.log(
@@ -220,6 +234,14 @@ export default async function handler(
break;
}
const admin = await findUserByStripeCustomerId(
subscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
const serversQuantity = getSubscriptionServersQuantity(
subscription?.items?.data ?? [],
);
@@ -233,18 +255,10 @@ export default async function handler(
),
);
const admin = await findUserByStripeCustomerId(
subscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
break;
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
await updateServersBasedOnQuantity(admin.id, serversQuantity);
if (admin.sendInvoiceNotifications) {
await sendInvoiceEmail(newInvoice, admin);
@@ -255,9 +269,12 @@ export default async function handler(
case "invoice.payment_failed": {
const newInvoice = event.data.object as Stripe.Invoice;
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
if (!subscriptionId) {
break;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status !== "active") {
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
@@ -351,6 +368,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
return userResult;
};
const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => {
const subscription = invoice.subscription;
if (typeof subscription === "string") return subscription;
return subscription?.id ?? null;
};
const activateServer = async (serverId: string) => {
await db
.update(server)
@@ -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 { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -195,13 +194,6 @@ const Service = (
{permissions?.service.create && (
<UpdateApplication applicationId={applicationId} />
)}
{permissions?.service.create && (
<TransferService
id={applicationId}
type="application"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<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 { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
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 { DeleteService } from "@/components/dashboard/compose/delete-service";
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 { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
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 { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -186,13 +184,6 @@ const Service = (
<UpdateCompose composeId={composeId} />
)}
{permissions?.service.create && (
<TransferService
id={composeId}
type="compose"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={composeId} type="compose" />
)}
@@ -443,7 +434,6 @@ const Service = (
<ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<FreshVolumes composeId={composeId} />
</div>
</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 { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -146,13 +145,6 @@ const Libsql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateLibsql libsqlId={libsqlId} />
{(auth?.role === "owner" || auth?.canCreateServices) && (
<TransferService
id={libsqlId}
type="libsql"
serverId={data?.serverId}
/>
)}
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<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 { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const Mariadb = (
{permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} />
)}
{permissions?.service.create && (
<TransferService
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<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 { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const Mongo = (
{permissions?.service.create && (
<UpdateMongo mongoId={mongoId} />
)}
{permissions?.service.create && (
<TransferService
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<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 { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -160,13 +159,6 @@ const MySql = (
{permissions?.service.create && (
<UpdateMysql mysqlId={mysqlId} />
)}
{permissions?.service.create && (
<TransferService
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<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 { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,13 +158,6 @@ const Postgresql = (
{permissions?.service.create && (
<UpdatePostgres postgresId={postgresId} />
)}
{permissions?.service.create && (
<TransferService
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<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 { UpdateRedis } from "@/components/dashboard/redis/update-redis";
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,13 +158,6 @@ const Redis = (
{permissions?.service.create && (
<UpdateRedis redisId={redisId} />
)}
{permissions?.service.create && (
<TransferService
id={redisId}
type="redis"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={redisId} type="redis" />
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -16,7 +16,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />}
@@ -15,7 +15,7 @@ const Page = () => {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">
@@ -25,7 +25,7 @@ interface Props {
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
@@ -12,7 +12,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
-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
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
await helpers.sso.showSignInWithSSO.prefetch();
if (IS_CLOUD) {
try {
-2
View File
@@ -53,7 +53,6 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider";
import { volumeBackupsRouter } from "./routers/volume-backups";
@@ -121,7 +120,6 @@ export const appRouter = createTRPCRouter({
tag: tagRouter,
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
});
// export type definition of API
@@ -5,7 +5,6 @@ import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import {
getOrganizationOwnerId,
markOrganizationOwnerVerified,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
@@ -213,7 +212,6 @@ export const ssoRouter = createTRPCRouter({
body: updateBody,
headers: requestToHeaders(ctx.req),
});
await markOrganizationOwnerVerified(ctx.session.activeOrganizationId);
return { success: true };
}),
deleteProvider: enterpriseProcedure
@@ -294,7 +292,6 @@ export const ssoRouter = createTRPCRouter({
},
headers: requestToHeaders(ctx.req),
});
await markOrganizationOwnerVerified(organizationId);
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
@@ -19,7 +19,6 @@ import {
getUpdateData,
getWebServerSettings,
IS_CLOUD,
markOrganizationOwnerVerified,
parseRawConfig,
paths,
prepareEnvironmentVariables,
@@ -468,9 +467,6 @@ export const settingsRouter = createTRPCRouter({
await updateWebServerSettings({
enforceSSO: input.enforceSSO,
});
if (input.enforceSSO) {
await markOrganizationOwnerVerified(ctx.session.activeOrganizationId);
}
await audit(ctx, {
action: "update",
+81 -65
View File
@@ -12,7 +12,7 @@ import {
getCurrentPlan as getCurrentPlanForOrganization,
getStripeClient,
TRIAL_DURATION_DAYS,
TRIAL_SERVER_LIMIT,
TRIAL_SERVER_LIMITS,
} from "@/server/utils/billing";
import {
type BillingTier,
@@ -45,72 +45,82 @@ export const stripeRouter = createTRPCRouter({
return getBillingStatus(ctx.user.ownerId);
}),
startFreeTrial: adminProcedure.mutation(async ({ ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
if (!HOBBY_PRICE_MONTHLY_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Trials are not configured",
});
}
const owner = await findUserById(ctx.user.ownerId);
const billingStatus = await getBillingStatus(owner.id);
if (billingStatus.hasActiveAccess) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You already have an active plan or trial",
});
}
if (billingStatus.hasUsedTrial) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You have already used your free trial",
});
}
const stripe = getStripeClient();
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
stripeCustomerId = null;
startFreeTrial: adminProcedure
.input(z.object({ tier: z.enum(["hobby", "startup"]) }))
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
}
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email: owner.email });
stripeCustomerId = customer.id;
}
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }],
trial_period_days: TRIAL_DURATION_DAYS,
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
metadata: { source: "onboarding_trial", adminId: owner.id },
});
const trialPriceId =
input.tier === "startup"
? STARTUP_BASE_PRICE_MONTHLY_ID
: HOBBY_PRICE_MONTHLY_ID;
if (!trialPriceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Trials are not configured",
});
}
await updateUser(owner.id, {
stripeCustomerId,
stripeSubscriptionId: subscription.id,
serversQuantity: TRIAL_SERVER_LIMIT,
});
const owner = await findUserById(ctx.user.ownerId);
const billingStatus = await getBillingStatus(owner.id);
return {
trialEndsAt: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
};
}),
if (billingStatus.hasActiveAccess) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You already have an active plan or trial",
});
}
if (billingStatus.hasUsedTrial) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You have already used your free trial",
});
}
const stripe = getStripeClient();
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
stripeCustomerId = null;
}
}
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email: owner.email });
stripeCustomerId = customer.id;
}
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: trialPriceId, quantity: 1 }],
trial_period_days: TRIAL_DURATION_DAYS,
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
metadata: {
source: "onboarding_trial",
adminId: owner.id,
tier: input.tier,
},
});
await updateUser(owner.id, {
stripeCustomerId,
stripeSubscriptionId: subscription.id,
serversQuantity: TRIAL_SERVER_LIMITS[input.tier],
});
return {
trialEndsAt: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
};
}),
getProducts: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
@@ -343,9 +353,15 @@ export const stripeRouter = createTRPCRouter({
});
}
const isTrialing = subscription.status === "trialing";
// Trials are capped at the plan's included servers; paid plans can scale.
const serverQuantity = isTrialing
? TRIAL_SERVER_LIMITS[input.tier]
: input.serverQuantity;
const newItems = getStripeItems(
input.tier as BillingTier,
input.serverQuantity,
serverQuantity,
input.isAnnual,
);
const currentItems = subscription.items.data;
@@ -371,7 +387,7 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: "create_prorations",
proration_behavior: isTrialing ? "none" : "create_prorations",
});
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;
}
}
}),
});
+17 -3
View File
@@ -16,7 +16,7 @@ export const getStripeClient = () =>
apiVersion: "2024-09-30.acacia",
});
const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
if (
priceIds.some(
(id) =>
@@ -78,7 +78,11 @@ export const getCurrentPlan = async (
};
export const TRIAL_DURATION_DAYS = 7;
export const TRIAL_SERVER_LIMIT = 1;
export type TrialTier = "hobby" | "startup";
export const TRIAL_SERVER_LIMITS: Record<TrialTier, number> = {
hobby: 1,
startup: 3,
};
export interface BillingStatus {
plan: BillingPlan | null;
@@ -87,6 +91,7 @@ export interface BillingStatus {
trialDaysRemaining: number | null;
hasUsedTrial: boolean;
hasActiveAccess: boolean;
hasPaymentMethod: boolean;
}
export const getBillingStatus = async (
@@ -100,6 +105,7 @@ export const getBillingStatus = async (
trialDaysRemaining: null,
hasUsedTrial: false,
hasActiveAccess: true,
hasPaymentMethod: true,
};
}
@@ -112,6 +118,7 @@ export const getBillingStatus = async (
trialDaysRemaining: null,
hasUsedTrial: false,
hasActiveAccess: false,
hasPaymentMethod: false,
};
}
@@ -119,7 +126,7 @@ export const getBillingStatus = async (
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "all",
expand: ["data.items.data.price"],
expand: ["data.items.data.price", "data.customer"],
});
const relevantSubs = subscriptions.data.filter(
@@ -133,6 +140,12 @@ export const getBillingStatus = async (
const trialingSub = subscriptions.data.find(
(sub) => sub.status === "trialing",
);
const currentSub = trialingSub ?? relevantSubs[0];
const customer = currentSub?.customer as Stripe.Customer | undefined;
const hasPaymentMethod =
!!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method;
const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000)
: null;
@@ -148,6 +161,7 @@ export const getBillingStatus = async (
isOnTrial: !!trialingSub,
trialEndsAt,
trialDaysRemaining,
hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub,
};
@@ -0,0 +1,78 @@
import { db } from "@dokploy/server/db";
import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
import { user } from "@/server/db/schema";
import { planFromPriceIds } from "@/server/utils/billing";
import { WEBSITE_URL } from "@/server/utils/stripe";
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
startup: "Startup",
legacy: "Legacy",
};
const customerIdOf = (subscription: Stripe.Subscription) =>
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const hasPaymentMethod = async (
stripe: Stripe,
subscription: Stripe.Subscription,
) => {
if (subscription.default_payment_method) return true;
const customer = await stripe.customers.retrieve(customerIdOf(subscription));
if (customer.deleted) return true;
return !!customer.invoice_settings?.default_payment_method;
};
export const sendTrialExpiringEmail = async (
stripe: Stripe,
subscription: Stripe.Subscription,
// Stripe retries a failing webhook for days, so the countdown is measured
// from when Stripe emitted the event rather than from the current time.
notifiedAtSeconds: number,
) => {
if (!subscription.trial_end) return false;
if (await hasPaymentMethod(stripe, subscription)) return false;
const owner = await db.query.user.findFirst({
where: eq(user.stripeCustomerId, customerIdOf(subscription)),
});
if (!owner || owner.isEnterpriseCloud) return false;
const trialEndsAt = new Date(subscription.trial_end * 1000);
const daysRemaining = Math.max(
1,
Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)),
);
const plan = planFromPriceIds(
subscription.items.data.map((item) => (item.price as Stripe.Price).id),
);
const htmlContent = await render(
TrialExpiringEmail({
userName: owner.firstName || "User",
planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
daysRemaining,
endsOn: format(trialEndsAt, "MMM dd, yyyy"),
billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`,
}),
);
await sendEmail({
email: owner.email,
subject:
daysRemaining === 1
? "Your Dokploy trial ends tomorrow"
: `Your Dokploy trial ends in ${daysRemaining} days`,
text: htmlContent,
});
return true;
};
+2 -2
View File
@@ -3,10 +3,9 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20
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/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
)
@@ -21,6 +20,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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/tklauser/go-sysconf v0.3.14 // 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/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/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+1 -3
View File
@@ -48,9 +48,7 @@
"overrides": {
"esbuild": "0.20.2",
"better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1",
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
"@better-fetch/fetch": "1.3.1"
},
"peerDependencyRules": {
"ignoreMissing": [
+1 -1
View File
@@ -82,7 +82,7 @@
"react-dom": "19.2.7",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.4",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"toml": "3.0.0",
-1
View File
@@ -97,7 +97,6 @@ export const compose = pgTable("compose", {
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
.notNull()
.default(false),
pullImages: boolean("pullImages").notNull().default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"),
-1
View File
@@ -41,7 +41,6 @@ export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./transfer";
export * from "./user";
export * from "./utils";
export * from "./vault-provider";
@@ -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),
});
@@ -0,0 +1,141 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
userName: string;
planName: string;
daysRemaining: number;
endsOn: string;
billingUrl: string;
};
export const TrialExpiringEmail = ({
userName = "User",
planName = "Hobby",
daysRemaining = 3,
endsOn = "2024-01-01",
billingUrl = "https://app.dokploy.com/dashboard/settings/billing",
}: TemplateProps) => {
const dayLabel = daysRemaining === 1 ? "day" : "days";
const previewText =
daysRemaining === 1
? "Your Dokploy trial ends tomorrow"
: `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`;
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]">
{daysRemaining === 1
? "Your trial ends tomorrow"
: `Your trial ends in ${daysRemaining} ${dayLabel}`}
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, add a payment method to keep your servers and
applications running once the trial ends.
</Text>
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Plan
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{planName}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Trial ends
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{endsOn}
</Text>
</Column>
</Row>
</Section>
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
Without a payment method your subscription is cancelled when
the trial ends and your servers are deactivated.
</Text>
</Section>
<Section className="text-center mb-[24px]">
<Button
href={billingUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Add Payment Method
</Button>
</Section>
<Hr className="border-[#e4e4e7] my-[24px]" />
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
You are billed only when the trial ends. You can cancel at any
time before then.
</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">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default TrialExpiringEmail;
+3939 -1259
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -54,7 +54,6 @@ export * from "./services/server";
export * from "./services/server-health";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/transfer";
export * from "./services/user";
export * from "./services/vault-provider";
export * from "./services/volume-backups";
@@ -121,7 +120,6 @@ export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./utils/process/execAsync";
export * from "./utils/process/remoteStream";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";
export * from "./utils/providers/docker";
@@ -1,6 +1,5 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { organization, user } from "@dokploy/server/db/schema";
import { organization } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const getSSOProviders = async () => {
@@ -45,14 +44,3 @@ export const getOrganizationOwnerId = async (organizationId: string) => {
if (!org) return null;
return org.ownerId;
};
// better-auth won't link SSO to an unverified user; self-hosted owners never verify
export const markOrganizationOwnerVerified = async (organizationId: string) => {
if (IS_CLOUD) return;
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) return;
await db
.update(user)
.set({ emailVerified: true })
.where(eq(user.id, ownerId));
};
-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,
};
};
@@ -139,8 +139,7 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
-20
View File
@@ -497,7 +497,6 @@ export interface ComposeSpecification {
*/
include?: DefinitionsInclude[];
services?: PropertiesServices;
models?: PropertiesModels;
networks?: PropertiesNetworks;
volumes?: PropertiesVolumes;
secrets?: PropertiesSecrets;
@@ -649,15 +648,6 @@ export interface DefinitionsService {
mem_swappiness?: number;
memswap_limit?: number | string;
network_mode?: string;
models?:
| ListOfStrings
| {
[k: string]: {
endpoint_var?: string;
model_var?: string;
[key: `x-${string}`]: unknown;
} | null;
};
networks?:
| ListOfStrings
| {
@@ -887,13 +877,3 @@ export interface DefinitionsConfig {
*/
[k: string]: unknown;
}
export interface PropertiesModels {
[k: string]: DefinitionsModel;
}
export interface DefinitionsModel {
name?: string;
model: string;
context_size?: number;
runtime_flags?: string[];
[key: `x-${string}`]: unknown;
}
@@ -196,7 +196,7 @@ export const sendDatabaseBackupNotifications = async ({
`🛠Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` +
`❔Type: ${databaseType}\n` +
`📂Database Name: ${databaseName}\n` +
`📂Database Name: ${databaseName}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
@@ -1,127 +0,0 @@
import { spawn } from "node:child_process";
import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
export interface ProcessStream {
stdin: NodeJS.WritableStream;
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
exit: Promise<number>;
close: () => void;
}
export const openProcessStream = async (
serverId: string | null,
command: string,
): Promise<ProcessStream> => {
if (!serverId) {
const child = spawn("sh", ["-c", command]);
const exit = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
return {
stdin: child.stdin,
stdout: child.stdout,
stderr: child.stderr,
exit,
close: () => child.kill(),
};
}
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
let exitCode = 1;
stream.once("exit", (code: number | null) => {
exitCode = code ?? 1;
});
const exit = new Promise<number>((resolveExit) => {
stream.once("close", () => {
conn.end();
resolveExit(exitCode);
});
});
resolve({
stdin: stream,
stdout: stream,
stderr: stream.stderr,
exit,
close: () => {
stream.close();
conn.end();
},
});
});
})
.once("error", (err) => {
conn.end();
reject(err);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
readyTimeout: 30_000,
keepaliveInterval: 15_000,
});
});
};
const collectOutput = (stream: NodeJS.ReadableStream) => {
const chunks: string[] = [];
stream.on("data", (chunk) => chunks.push(chunk.toString()));
return () => chunks.join("").trim();
};
export const pipeBetweenServers = async ({
source,
target,
onProgress,
}: {
source: { serverId: string | null; command: string };
target: { serverId: string | null; command: string };
onProgress?: (bytes: number) => void;
}) => {
const to = await openProcessStream(target.serverId, target.command);
const targetError = collectOutput(to.stderr);
to.stdout.resume();
to.stdin.on("error", () => {});
const from = await openProcessStream(source.serverId, source.command);
const sourceError = collectOutput(from.stderr);
let bytes = 0;
from.stdout.on("data", (chunk: Buffer) => {
bytes += chunk.length;
onProgress?.(bytes);
});
from.stdout.pipe(to.stdin);
const targetExit = to.exit.then((code) => {
if (code !== 0) from.close();
return code;
});
const [sourceCode, targetCode] = await Promise.all([from.exit, targetExit]);
const failures = [
sourceCode !== 0 &&
`source exited with code ${sourceCode}: ${sourceError()}`,
targetCode !== 0 &&
`target exited with code ${targetCode}: ${targetError()}`,
].filter(Boolean);
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return bytes;
};
@@ -79,12 +79,6 @@ const readPath = async (
// still reports success. Single secrets read via /raw/{name} expand by
// default, which makes the difference easy to miss in the UI.
expandSecretReferences: "true",
// Secrets pulled in through "Import Secrets" are not part of `secrets`:
// they come back in a separate `imports` array, and only when this is
// asked for. Note the snake_case — `includeImports` is silently ignored
// (the request still returns 200 with an empty `imports`), which is why
// an imported secret looked like it simply did not exist.
include_imports: "true",
});
const response = await vaultFetch(
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
@@ -99,17 +93,9 @@ const readPath = async (
const body = (await response.json()) as {
secrets?: { secretKey: string; secretValue: string }[];
imports?: { secrets?: { secretKey: string; secretValue: string }[] }[];
};
const secrets: Record<string, string> = {};
// Imported first, then the folder's own: Infisical resolves a name defined
// in both in favour of the local one, so writing local last preserves that.
for (const imported of body.imports ?? []) {
for (const secret of imported.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
}
for (const secret of body.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
+401 -390
View File
File diff suppressed because it is too large Load Diff