mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-18 22:51:36 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12784ec0ff | |||
| a0e0574bcd | |||
| c453ac3a2f | |||
| f397c1a862 | |||
| 16ac4633f3 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 88118cdb3f | |||
| 38d157adc0 | |||
| 3b1fec5409 | |||
| 49b8214e5d | |||
| ec31739e0e | |||
| d5328703fa | |||
| f4cb26cbf4 | |||
| a6c1f3048f | |||
| 9cae609a0a | |||
| 2c234d6e20 | |||
| 9dcdb53aa1 | |||
| bda8124291 | |||
| 96778433db | |||
| f25810fa72 | |||
| 341f38bba6 | |||
| 19589694ea | |||
| 5bc53bfbff | |||
| 549b124fcd | |||
| 0c5da0b36f | |||
| ee18724dd7 | |||
| 1ae9b4025c | |||
| 5bc870dc2d | |||
| 299950a323 |
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
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 [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||
if [ "$VERSION" != "$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: ${{ env.VERSION }}"
|
||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||
echo "Current version: $VERSION"
|
||||
echo "Latest tag: $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 ${{ env.VERSION }}" \
|
||||
--body '
|
||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||
--title "🚀 Release $VERSION" \
|
||||
--body "
|
||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||
|
||||
### 🔍 Changes Include:
|
||||
- Version bump to ${{ env.VERSION }}
|
||||
- Version bump to $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 \
|
||||
|
||||
@@ -5,33 +5,38 @@ 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@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
@@ -48,26 +53,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
@@ -80,26 +87,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
|
||||
@@ -8,18 +8,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -27,15 +32,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: |
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -53,13 +58,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -68,15 +75,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: |
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -96,20 +103,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
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"
|
||||
|
||||
@@ -121,7 +130,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 \
|
||||
@@ -138,32 +147,37 @@ 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@v4
|
||||
uses: actions/checkout@v7
|
||||
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:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
@@ -179,15 +193,19 @@ 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@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
@@ -202,63 +220,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 "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$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 ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ MCP repo synced to version $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 "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$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 ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ CLI repo synced to version $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 "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$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 ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||
|
||||
@@ -6,15 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
@@ -16,17 +16,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
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="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
SHA="$MERGE_SHA"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
|
||||
@@ -7,12 +7,16 @@ 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@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
@@ -7,18 +7,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/monitoring
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -26,17 +31,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@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -47,13 +52,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -61,17 +68,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@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -84,27 +91,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
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 \
|
||||
|
||||
@@ -14,9 +14,11 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -12,17 +12,20 @@ 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@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -28,6 +28,9 @@ env:
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
@@ -421,8 +424,10 @@ 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^^}
|
||||
|
||||
+8403
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
|
||||
HOSTINGER_API_KEY=
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
composePath: "./docker-compose.yml",
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --pull always", () => {
|
||||
it("adds --pull always when pullImages is enabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||
|
||||
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||
});
|
||||
|
||||
it("omits --pull when pullImages is disabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("does not add --pull to stack deploy", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("leaves a custom command untouched", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -p compose-app up -d",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).toBe("compose -p compose-app up -d");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
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
@@ -472,6 +472,77 @@ 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())
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
||||
|
||||
describe("pipeBetweenServers", () => {
|
||||
it("delivers a short source stream that ends before the target is ready", async () => {
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf 'hello world'" },
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
const source = path.join(dir, "src.bin");
|
||||
const target = path.join(dir, "dst.bin");
|
||||
const size = 1024 * 1024;
|
||||
const progress: number[] = [];
|
||||
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: {
|
||||
serverId: null,
|
||||
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
||||
},
|
||||
target: { serverId: null, command: `cat > '${target}'` },
|
||||
onProgress: (transferred) => progress.push(transferred),
|
||||
});
|
||||
|
||||
expect(bytes).toBe(size);
|
||||
expect(progress.at(-1)).toBe(size);
|
||||
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("reports a target failure with its stderr", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf x" },
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "exit 2" },
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,45 @@ import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.39.0",
|
||||
"0.38.0",
|
||||
"0.37.1",
|
||||
"0.37.0",
|
||||
"0.36.4",
|
||||
"0.36.3",
|
||||
"0.36.2",
|
||||
"0.36.1",
|
||||
"0.36.0",
|
||||
"0.35.0",
|
||||
"0.34.0",
|
||||
"0.33.0",
|
||||
"0.32.0",
|
||||
"0.31.2",
|
||||
"0.31.1",
|
||||
"0.31.0",
|
||||
"0.30.1",
|
||||
"0.30.0",
|
||||
"0.29.0",
|
||||
"0.28.0",
|
||||
"0.27.2",
|
||||
"0.27.1",
|
||||
"0.27.0",
|
||||
"0.26.1",
|
||||
"0.26.0",
|
||||
"0.25.0",
|
||||
"0.24.0",
|
||||
"0.23.0",
|
||||
"0.22.2",
|
||||
"0.22.1",
|
||||
"0.22.0",
|
||||
"0.21.0",
|
||||
"0.20.0",
|
||||
"0.19.0",
|
||||
"0.18.0",
|
||||
"0.17.2",
|
||||
"0.17.1",
|
||||
"0.17.0",
|
||||
"0.16.0",
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
@@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.2",
|
||||
"0.7.1",
|
||||
"0.7.0",
|
||||
"0.6.1",
|
||||
"0.6.0",
|
||||
"0.5.1",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.3",
|
||||
"0.2.2",
|
||||
"0.2.1",
|
||||
"0.2.0",
|
||||
"0.1.2",
|
||||
"0.1.1",
|
||||
"0.1.0",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
|
||||
@@ -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,6 +22,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
pullImages: z.boolean(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
pullImages: false,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.command) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
command: data.command || "",
|
||||
pullImages: data.pullImages ?? false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
const onSubmit = async (formData: AddCommand) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
command: data?.command,
|
||||
command: formData.command,
|
||||
pullImages: formData.pullImages,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
<strong>docker</strong>.
|
||||
</AlertBlock>
|
||||
<div className="flex flex-col gap-4">
|
||||
{data?.composeType === "docker-compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pullImages"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Pull latest images on deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Adds <strong>--pull always</strong> to the default
|
||||
command so every deploy fetches the newest image for
|
||||
each tag. Has no effect when a custom command is set.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const freshVolumesSchema = z.object({
|
||||
confirmName: z.string().min(1, {
|
||||
message: "Compose name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const FreshVolumes = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{ composeId },
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
||||
|
||||
const form = useForm<FreshVolumesForm>({
|
||||
defaultValues: { confirmName: "" },
|
||||
resolver: zodResolver(freshVolumesSchema),
|
||||
});
|
||||
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
const isRunning = data?.composeStatus === "running";
|
||||
|
||||
const onSubmit = async (formData: FreshVolumesForm) => {
|
||||
if (formData.confirmName !== expectedName) {
|
||||
form.setError("confirmName", {
|
||||
message: `Compose name must match "${expectedName}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await deploy({ composeId, freshVolumes: true })
|
||||
.then(() => {
|
||||
toast.success("Compose deployed with fresh volumes");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
};
|
||||
|
||||
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
Deploy with Fresh Volumes
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently wipes every volume of this compose and redeploys it
|
||||
from a clean state. All persistent data (databases, uploads,
|
||||
caches) will be lost.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) form.reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
variant="outline"
|
||||
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||
>
|
||||
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||
Deploy with Fresh Volumes
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Are you absolutely sure?
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>This action will:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Stop the current compose</li>
|
||||
<li>Delete all volumes and their data</li>
|
||||
<li>Redeploy the compose with empty volumes</li>
|
||||
</ul>
|
||||
<p className="font-medium text-destructive mt-4">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-fresh-volumes"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
copy(expectedName);
|
||||
toast.success(
|
||||
"Copied to clipboard. Be careful!",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{expectedName}
|
||||
<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,11 +1,4 @@
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HardDriveDownload,
|
||||
RefreshCcw,
|
||||
Rocket,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { toast } from "sonner";
|
||||
@@ -89,51 +82,6 @@ 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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between">
|
||||
<div>
|
||||
@@ -62,7 +62,7 @@ export const AiForm = () => {
|
||||
key={config.aiId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import { CreditCard, FileText, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
@@ -29,7 +34,7 @@ export const ShowBillingInvoices = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -83,6 +84,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
@@ -105,13 +111,53 @@ 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",
|
||||
isAnnualPlan: boolean,
|
||||
label?: string,
|
||||
) => {
|
||||
const planName =
|
||||
label ??
|
||||
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||
|
||||
const switchPlan = async () => {
|
||||
await upgradeSubscription({
|
||||
tier,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: isAnnualPlan,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
utils.server.count.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
setSwitchingTrial(true);
|
||||
await toast
|
||||
.promise(switchPlan(), {
|
||||
loading: `Switching to ${planName}...`,
|
||||
success: `Your trial is now on ${planName}`,
|
||||
error: (error) =>
|
||||
error instanceof Error ? error.message : "Error switching plan",
|
||||
})
|
||||
.unwrap()
|
||||
.catch(() => undefined);
|
||||
setSwitchingTrial(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||
setTrialTier(tier);
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await startFreeTrial({ tier });
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
@@ -122,6 +168,8 @@ export const ShowBilling = () => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setTrialTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,12 +218,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);
|
||||
@@ -289,6 +348,11 @@ export const ShowBilling = () => {
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
{billingStatus.plan && (
|
||||
<Badge variant="outline">
|
||||
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
@@ -296,67 +360,94 @@ 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 &&
|
||||
billingStatus.plan !== "legacy" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan === "startup"
|
||||
? "hobby"
|
||||
: "startup",
|
||||
billingStatus.isAnnual,
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.plan === "startup"
|
||||
? "Switch to Hobby"
|
||||
: "Switch to Startup"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan as "hobby" | "startup",
|
||||
!billingStatus.isAnnual,
|
||||
billingStatus.isAnnual
|
||||
? "monthly billing"
|
||||
: "annual billing",
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.isAnnual
|
||||
? "Switch to monthly"
|
||||
: "Switch to annual"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="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 +1024,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 +1161,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 +1429,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>
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: <Clock className="size-3" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
provisioning: {
|
||||
label: "Provisioning",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
configuring: {
|
||||
label: "Installing Dokploy",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
variant: "default",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
icon: <XCircle className="size-3" />,
|
||||
variant: "destructive",
|
||||
},
|
||||
terminating: {
|
||||
label: "Terminating",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
terminated: {
|
||||
label: "Terminated",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
variant: "outline",
|
||||
},
|
||||
};
|
||||
|
||||
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
|
||||
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
return `$${(priceCents / 100).toFixed(2)}/mo`;
|
||||
}
|
||||
|
||||
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
|
||||
|
||||
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||
const [selectedZone, setSelectedZone] = useState<string>("");
|
||||
|
||||
const { data: plans, isLoading: loadingPlans } =
|
||||
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||
const { data: zones, isLoading: loadingZones } =
|
||||
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||
|
||||
const isLoadingOptions = loadingPlans || loadingZones;
|
||||
|
||||
const purchase = api.managedServer.purchase.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||
|
||||
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
|
||||
if (!acc[z.continent]) acc[z.continent] = [];
|
||||
acc[z.continent]!.push(z);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Order Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll provision and configure a server for you automatically. Ready
|
||||
in ~5 minutes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading available plans...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Plan cards */}
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{plans?.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlan(p.id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedPlan === p.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{formatPrice(p.priceCents)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone selector grouped by continent */}
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<Select value={selectedZone} onValueChange={setSelectedZone}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a location..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
|
||||
<div key={continent}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{continent}
|
||||
</div>
|
||||
{groupedZones?.[continent]?.map((z) => (
|
||||
<SelectItem key={z.id} value={z.id}>
|
||||
{z.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{plan && selectedZone && (
|
||||
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Specs</span>
|
||||
<span className="font-medium">
|
||||
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zone</span>
|
||||
<span className="font-medium">
|
||||
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedPlan || !selectedZone || purchase.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedPlan || !selectedZone) return;
|
||||
purchase.mutate({
|
||||
plan: selectedPlan,
|
||||
zone: selectedZone,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{purchase.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Placing order...
|
||||
</>
|
||||
) : (
|
||||
"Order Server"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
hobby: "Hobby",
|
||||
starter: "Starter",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
};
|
||||
|
||||
export const ShowManagedServers = () => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||
|
||||
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||
});
|
||||
|
||||
const reconnect = api.managedServer.reconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server reconnected successfully.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteServer = api.managedServer.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server terminated.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and servers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servers provisioned and managed by Dokploy Cloud
|
||||
</p>
|
||||
</div>
|
||||
<OrderServerDialog
|
||||
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers?.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No managed servers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Order a server and we'll provision and configure it for you
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers?.map((s) => {
|
||||
const status =
|
||||
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||
const isProvisioning = [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
].includes(s.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.managedServerId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PLAN_LABELS[s.plan] ?? s.plan}
|
||||
</span>
|
||||
<Badge
|
||||
variant={status?.variant}
|
||||
className="flex items-center gap-1 text-xs h-5"
|
||||
>
|
||||
{status?.icon}
|
||||
{status?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.hostname ?? ""}
|
||||
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||
{s.zone ? ` · ${s.zone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isProvisioning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncStatus.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={syncStatus.isPending}
|
||||
>
|
||||
<Loader2
|
||||
className={cn(
|
||||
"size-4",
|
||||
syncStatus.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && !s.serverId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reconnect.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={reconnect.isPending}
|
||||
>
|
||||
{reconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && s.server && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Terminate Server"
|
||||
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={() =>
|
||||
deleteServer.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
|
||||
key={certificate.certificateId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
|
||||
key={registry.registryId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
|
||||
key={destination.destinationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId });
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<span className="text-sm flex flex-row items-center gap-4">
|
||||
{notification.notificationType === "slack" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
|
||||
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -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 max-w-5xl mx-auto">
|
||||
<Card className="h-full p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
|
||||
key={sshKey.sshKeyId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -22,7 +22,7 @@ export const TagManager = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
<form
|
||||
id="hook-form-add-permissions"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
|
||||
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
|
||||
>
|
||||
{isCustomRole && (
|
||||
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WebDomain = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const WebServer = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { type LogLine, parseLogs } from "../docker/logs/utils";
|
||||
|
||||
const LOCAL_SERVER = "dokploy";
|
||||
|
||||
const transferSchema = z.object({
|
||||
targetServerId: z.string().min(1, { message: "Select a target server" }),
|
||||
removeSourceData: z.boolean(),
|
||||
});
|
||||
|
||||
type TransferForm = z.infer<typeof transferSchema>;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: ServiceType;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const TransferService = ({ id, type, serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||
const [request, setRequest] = useState<{
|
||||
targetServerId: string | null;
|
||||
removeSourceData: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const targets = [
|
||||
...(!isCloud && serverId
|
||||
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
|
||||
: []),
|
||||
...(servers ?? []).filter((server) => server.serverId !== serverId),
|
||||
];
|
||||
|
||||
const form = useForm<TransferForm>({
|
||||
defaultValues: { targetServerId: "", removeSourceData: false },
|
||||
resolver: zodResolver(transferSchema),
|
||||
});
|
||||
|
||||
api.transfer.start.useSubscription(
|
||||
{
|
||||
serviceType: type,
|
||||
serviceId: id,
|
||||
targetServerId: request?.targetServerId ?? null,
|
||||
removeSourceData: request?.removeSourceData ?? false,
|
||||
},
|
||||
{
|
||||
enabled: isTransferring && request !== null,
|
||||
onData(line) {
|
||||
setLogs((prev) => [...prev, ...parseLogs(line)]);
|
||||
if (line.startsWith("Transfer completed")) {
|
||||
setIsTransferring(false);
|
||||
toast.success("Service transferred successfully");
|
||||
utils.invalidate();
|
||||
} else if (line.startsWith("Transfer failed")) {
|
||||
setIsTransferring(false);
|
||||
toast.error("Transfer failed, check the logs");
|
||||
utils.invalidate();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setIsTransferring(false);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = (values: TransferForm) => {
|
||||
setRequest({
|
||||
targetServerId:
|
||||
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
|
||||
removeSourceData: values.removeSourceData,
|
||||
});
|
||||
setLogs([]);
|
||||
setIsDrawerOpen(true);
|
||||
setIsTransferring(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
isLoading={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer to another server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Moves this service with its volumes, bind mounts, file mounts and
|
||||
configuration to the selected server, then deploys it there.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{targets.length === 0 ? (
|
||||
<AlertBlock type="info">
|
||||
There are no other servers available to transfer this service to.
|
||||
</AlertBlock>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-transfer-service"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{targets.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="removeSourceData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="ml-2">
|
||||
Remove volumes from the source server after the
|
||||
transfer
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<AlertBlock type="warning">
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>The service is stopped while its data is copied.</li>
|
||||
<li>
|
||||
Point your DNS records to the new server, certificates are
|
||||
issued again there.
|
||||
</li>
|
||||
<li>
|
||||
Networks that only exist on the current server are
|
||||
detached.
|
||||
</li>
|
||||
<li>
|
||||
Bind mount host paths are copied but never deleted from
|
||||
the source server.
|
||||
</li>
|
||||
</ul>
|
||||
</AlertBlock>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{targets.length > 0 && (
|
||||
<Button
|
||||
isLoading={isTransferring}
|
||||
form="hook-form-transfer-service"
|
||||
type="submit"
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
filteredLogs={logs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
|
||||
|
||||
export const ManageCustomRoles = () => {
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -30,6 +30,7 @@ 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: (
|
||||
@@ -83,6 +84,11 @@ 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,6 +35,16 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"models": {
|
||||
"id": "#/properties/models",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9._-]+$": {
|
||||
"$ref": "#/definitions/model"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"networks": {
|
||||
"id": "#/properties/networks",
|
||||
"type": "object",
|
||||
@@ -335,6 +345,30 @@
|
||||
"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" },
|
||||
@@ -890,6 +924,23 @@
|
||||
"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" },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||
CREATE TABLE "managed_server" (
|
||||
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"plan" text NOT NULL,
|
||||
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||
"providerVmId" text,
|
||||
"zone" text NOT NULL,
|
||||
"sshKeyId" text,
|
||||
"ipAddress" text,
|
||||
"hostname" text,
|
||||
"stripeSubscriptionId" text,
|
||||
"stripePriceId" text,
|
||||
"errorMessage" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1373,6 +1373,20 @@
|
||||
"when": 1788867370653,
|
||||
"tag": "0195_classy_whirlwind",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 196,
|
||||
"version": "7",
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1790609715152,
|
||||
"tag": "0197_aberrant_psynapse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.4",
|
||||
"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.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"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.0.18"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
@@ -36,6 +36,7 @@ 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";
|
||||
@@ -194,6 +195,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+10
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -33,6 +34,7 @@ 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";
|
||||
@@ -184,6 +186,13 @@ const Service = (
|
||||
<UpdateCompose composeId={composeId} />
|
||||
)}
|
||||
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={composeId}
|
||||
type="compose"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={composeId} type="compose" />
|
||||
)}
|
||||
@@ -434,6 +443,7 @@ const Service = (
|
||||
<ShowImport composeId={composeId} />
|
||||
<AssignComposeNetworks composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
<FreshVolumes composeId={composeId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -145,6 +146,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -159,6 +160,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -159,6 +160,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -159,6 +160,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -158,6 +159,13 @@ 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" />
|
||||
)}
|
||||
|
||||
+8
@@ -22,6 +22,7 @@ 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";
|
||||
@@ -158,6 +159,13 @@ 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
const Page = () => {
|
||||
return <ShowManagedServers />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext,
|
||||
) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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">
|
||||
|
||||
@@ -498,6 +498,7 @@ 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 {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
@@ -53,6 +54,7 @@ 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";
|
||||
@@ -120,6 +122,8 @@ export const appRouter = createTRPCRouter({
|
||||
tag: tagRouter,
|
||||
patch: patchRouter,
|
||||
overview: overviewRouter,
|
||||
transfer: transferRouter,
|
||||
managedServer: managedServerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createServer,
|
||||
IS_CLOUD,
|
||||
serverSetup,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiCreateManagedServer,
|
||||
apiDeleteManagedServer,
|
||||
apiFindOneManagedServer,
|
||||
} from "@dokploy/server/db/schema/managed-server";
|
||||
import {
|
||||
createManagedServer,
|
||||
deleteManagedServer,
|
||||
findManagedServerById,
|
||||
findManagedServersByOrg,
|
||||
updateManagedServer,
|
||||
} from "@dokploy/server/services/managed-server";
|
||||
import { createSshKey } from "@dokploy/server/services/ssh-key";
|
||||
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
|
||||
import {
|
||||
DOKPLOY_PLANS,
|
||||
createUpCloudServer,
|
||||
deleteUpCloudServer,
|
||||
getPublicIPv4,
|
||||
getUpCloudServer,
|
||||
getUpCloudZones,
|
||||
stopUpCloudServer,
|
||||
} from "@dokploy/server/utils/upcloud";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||
|
||||
|
||||
export const managedServerRouter = createTRPCRouter({
|
||||
getPlans: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return DOKPLOY_PLANS;
|
||||
}),
|
||||
|
||||
getDataCenters: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getUpCloudZones();
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return [];
|
||||
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
one: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return record;
|
||||
}),
|
||||
|
||||
purchase: adminProcedure
|
||||
.input(apiCreateManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
|
||||
if (!plan) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||
}
|
||||
|
||||
const hostname =
|
||||
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||
|
||||
const managedRecord = await createManagedServer({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
plan: input.plan,
|
||||
zone: input.zone,
|
||||
status: "provisioning",
|
||||
});
|
||||
|
||||
provisionManagedServer(
|
||||
managedRecord.managedServerId,
|
||||
plan.upcloudPlan,
|
||||
input.zone,
|
||||
hostname,
|
||||
ctx.session.activeOrganizationId,
|
||||
).catch(async (err) => {
|
||||
const responseBody = err?.response?.data;
|
||||
const detail =
|
||||
responseBody?.message ??
|
||||
responseBody?.error ??
|
||||
(typeof responseBody === "string" ? responseBody : null);
|
||||
const errorMessage = detail
|
||||
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
|
||||
: (err?.message ?? "Unknown error during provisioning");
|
||||
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
|
||||
await updateManagedServer(managedRecord.managedServerId, {
|
||||
status: "error",
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
return managedRecord;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(apiDeleteManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
status: "terminating",
|
||||
});
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
|
||||
(err) => console.error("[managed-server] termination failed:", err),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
reconnect: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (record.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is already linked",
|
||||
});
|
||||
}
|
||||
if (!record.ipAddress) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No IP address available to reconnect",
|
||||
});
|
||||
}
|
||||
|
||||
if (!record.sshKeyId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No SSH key found for this managed server",
|
||||
});
|
||||
}
|
||||
|
||||
const name = record.hostname ?? record.managedServerId;
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${name}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: record.ipAddress,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: record.sshKeyId,
|
||||
},
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
|
||||
syncStatus: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
if (!upcloudUuid) return record;
|
||||
|
||||
const vm = await getUpCloudServer(upcloudUuid);
|
||||
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
ipAddress,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status:
|
||||
vm.state === "started"
|
||||
? record.serverId
|
||||
? "ready"
|
||||
: "configuring"
|
||||
: record.status,
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
});
|
||||
|
||||
async function provisionManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudPlan: string,
|
||||
zone: string,
|
||||
hostname: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const { publicKey, privateKey } = await generateSSHKey("rsa");
|
||||
|
||||
const vm = await createUpCloudServer({
|
||||
hostname,
|
||||
upcloudPlan,
|
||||
zone,
|
||||
sshKey: publicKey,
|
||||
});
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
providerVmId: vm.uuid,
|
||||
hostname: vm.hostname,
|
||||
status: "configuring",
|
||||
});
|
||||
|
||||
await waitForServerStarted(vm.uuid, managedServerId);
|
||||
|
||||
const finalVm = await getUpCloudServer(vm.uuid);
|
||||
const finalIp = getPublicIPv4(finalVm);
|
||||
|
||||
if (!finalIp) {
|
||||
throw new Error("VM is started but has no public IPv4 address");
|
||||
}
|
||||
|
||||
const sshKey = await createSshKey({
|
||||
name: hostname,
|
||||
publicKey,
|
||||
privateKey,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!sshKey) throw new Error("Failed to create SSH key");
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${hostname}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: finalIp,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
},
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
ipAddress: finalIp,
|
||||
});
|
||||
|
||||
await serverSetup(serverRecord.serverId);
|
||||
await updateManagedServer(managedServerId, { status: "ready" });
|
||||
}
|
||||
|
||||
async function waitForServerStarted(
|
||||
uuid: string,
|
||||
_managedServerId: string,
|
||||
maxAttempts = 30,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "started") return;
|
||||
if (vm.state === "error") throw new Error("VM entered error state");
|
||||
}
|
||||
throw new Error("Timed out waiting for VM to start");
|
||||
}
|
||||
|
||||
async function waitForServerStopped(
|
||||
uuid: string,
|
||||
maxAttempts = 18,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "stopped") return;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudUuid: string | null,
|
||||
) {
|
||||
if (upcloudUuid) {
|
||||
await stopUpCloudServer(upcloudUuid);
|
||||
await waitForServerStopped(upcloudUuid);
|
||||
await deleteUpCloudServer(upcloudUuid);
|
||||
}
|
||||
await deleteManagedServer(managedServerId);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const linkedManagedServer = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.sshKeyId, input.sshKeyId),
|
||||
});
|
||||
if (linkedManagedServer) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This SSH key is used by a managed server and cannot be deleted.",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "sshKey",
|
||||
|
||||
@@ -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,10 @@ export const stripeRouter = createTRPCRouter({
|
||||
|
||||
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
|
||||
items: updateItems,
|
||||
proration_behavior: "create_prorations",
|
||||
proration_behavior: isTrialing ? "none" : "create_prorations",
|
||||
...(isTrialing && subscription.trial_end
|
||||
? { trial_end: subscription.trial_end }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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,8 @@ export interface BillingStatus {
|
||||
trialDaysRemaining: number | null;
|
||||
hasUsedTrial: boolean;
|
||||
hasActiveAccess: boolean;
|
||||
hasPaymentMethod: boolean;
|
||||
isAnnual: boolean;
|
||||
}
|
||||
|
||||
export const getBillingStatus = async (
|
||||
@@ -100,6 +106,8 @@ export const getBillingStatus = async (
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: true,
|
||||
hasPaymentMethod: true,
|
||||
isAnnual: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +120,8 @@ export const getBillingStatus = async (
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: false,
|
||||
hasPaymentMethod: false,
|
||||
isAnnual: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +129,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 +143,15 @@ 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 isAnnual =
|
||||
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
|
||||
?.interval === "year";
|
||||
const trialEndsAt = trialingSub?.trial_end
|
||||
? new Date(trialingSub.trial_end * 1000)
|
||||
: null;
|
||||
@@ -148,7 +167,9 @@ export const getBillingStatus = async (
|
||||
isOnTrial: !!trialingSub,
|
||||
trialEndsAt,
|
||||
trialDaysRemaining,
|
||||
hasPaymentMethod,
|
||||
hasUsedTrial: subscriptions.data.length > 0,
|
||||
hasActiveAccess: plan !== null || !!trialingSub,
|
||||
isAnnual,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -3,9 +3,10 @@ module github.com/mauriciogm/dokploy/apps/monitoring
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.6
|
||||
github.com/gofiber/fiber/v2 v2.52.11
|
||||
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
|
||||
)
|
||||
|
||||
@@ -20,7 +21,6 @@ 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
|
||||
|
||||
@@ -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.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
|
||||
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
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/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,8 +49,6 @@ 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=
|
||||
|
||||
+3
-1
@@ -48,7 +48,9 @@
|
||||
"overrides": {
|
||||
"esbuild": "0.20.2",
|
||||
"better-call": "1.3.7",
|
||||
"@better-fetch/fetch": "1.3.1"
|
||||
"@better-fetch/fetch": "1.3.1",
|
||||
"tar": ">=7.5.19 <8.0.0",
|
||||
"protobufjs": ">=7.5.5 <8.0.0"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"hostinger-api-sdk": "^0.0.17",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
@@ -82,7 +83,7 @@
|
||||
"react-dom": "19.2.7",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "~1.16.0",
|
||||
"toml": "3.0.0",
|
||||
|
||||
@@ -97,6 +97,7 @@ 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"),
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./managed-server";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
@@ -41,6 +42,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
|
||||
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export const managedServer = pgTable("managed_server", {
|
||||
managedServerId: text("managedServerId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** UpCloud plan name, e.g. "2xCPU-4GB" */
|
||||
plan: text("plan").notNull(),
|
||||
status: managedServerStatus("status").notNull().default("pending"),
|
||||
/** UpCloud server UUID */
|
||||
providerVmId: text("providerVmId"),
|
||||
/** UpCloud zone ID, e.g. "fi-hel1" */
|
||||
zone: text("zone").notNull(),
|
||||
/** SSH key used to access this server */
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
ipAddress: text("ipAddress"),
|
||||
hostname: text("hostname"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
stripePriceId: text("stripePriceId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [managedServer.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [managedServer.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apiCreateManagedServer = z.object({
|
||||
plan: z.string().min(1),
|
||||
zone: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
+1255
-3935
File diff suppressed because it is too large
Load Diff
@@ -50,10 +50,12 @@ export * from "./services/registry";
|
||||
export * from "./services/rollbacks";
|
||||
export * from "./services/schedule";
|
||||
export * from "./services/security";
|
||||
export * from "./services/managed-server";
|
||||
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";
|
||||
@@ -120,6 +122,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ManagedServer = typeof managedServer.$inferSelect;
|
||||
|
||||
export const createManagedServer = async (
|
||||
input: typeof managedServer.$inferInsert,
|
||||
) => {
|
||||
const record = await db
|
||||
.insert(managedServer)
|
||||
.values(input)
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServerById = async (managedServerId: string) => {
|
||||
const record = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.managedServerId, managedServerId),
|
||||
with: { server: true },
|
||||
});
|
||||
if (!record)
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServersByOrg = async (organizationId: string) => {
|
||||
return db.query.managedServer.findMany({
|
||||
where: eq(managedServer.organizationId, organizationId),
|
||||
with: { server: true },
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateManagedServer = async (
|
||||
managedServerId: string,
|
||||
data: Partial<typeof managedServer.$inferInsert>,
|
||||
) => {
|
||||
return db
|
||||
.update(managedServer)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(managedServer.managedServerId, managedServerId))
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
};
|
||||
|
||||
export const deleteManagedServer = async (managedServerId: string) => {
|
||||
return db
|
||||
.delete(managedServer)
|
||||
.where(eq(managedServer.managedServerId, managedServerId));
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const sshKey = await tx
|
||||
.insert(sshKeys)
|
||||
.values(input)
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
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,7 +139,8 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
const envFileFlag = compose.createEnvFile
|
||||
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
|
||||
: "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
|
||||
const pullFlag = compose.pullImages ? " --pull always" : "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
|
||||
} else if (composeType === "stack") {
|
||||
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||
}
|
||||
|
||||
@@ -497,6 +497,7 @@ export interface ComposeSpecification {
|
||||
*/
|
||||
include?: DefinitionsInclude[];
|
||||
services?: PropertiesServices;
|
||||
models?: PropertiesModels;
|
||||
networks?: PropertiesNetworks;
|
||||
volumes?: PropertiesVolumes;
|
||||
secrets?: PropertiesSecrets;
|
||||
@@ -648,6 +649,15 @@ 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
|
||||
| {
|
||||
@@ -877,3 +887,13 @@ 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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user